@map-audio/pam-mcp-server 1.25.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 +2 -1
- package/dist/audioAnalysis.d.ts +1 -1
- package/dist/audioAnalysis.js +3 -4
- package/dist/audioAnalysis.js.map +1 -1
- package/dist/bridge.js +7 -8
- 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.js +0 -19
- package/dist/generated/variationSnapshot.js.map +1 -1
- package/dist/generativePlayers.json +1 -1
- package/dist/manifest-mfx.json +35 -45
- package/dist/manifest-msq.json +9564 -0
- package/dist/manifest.js +5 -0
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +9139 -2428
- package/dist/mfxCombos.js +1 -1
- package/dist/mfxCombos.js.map +1 -1
- package/dist/midiFile.d.ts +1 -1
- package/dist/midiFile.js +1 -1
- package/dist/midiFile.js.map +1 -1
- 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/presetSave.d.ts +3 -0
- package/dist/presetSave.js +13 -0
- package/dist/presetSave.js.map +1 -1
- package/dist/server.js +835 -315
- package/dist/server.js.map +1 -1
- package/dist/targetDenylist.json +20 -0
- package/dist/variationSessionKeys.json +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -4,12 +4,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
|
|
7
|
+
import { CELL_CONFIG_KV_OPTIONS, EFFECT_CONFIG_KV_OPTIONS, MASTER_CONFIG_KV_OPTIONS, PATTERN_EDIT_KV_OPTIONS, STATE_UPDATE_KV_OPTIONS, } from "./generated/kvWriteOptions.js";
|
|
7
8
|
import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
|
|
8
9
|
import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
|
|
9
10
|
import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
|
|
10
11
|
import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
|
|
11
12
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
14
|
import { execFileSync } from "node:child_process";
|
|
14
15
|
import { inflateSync } from "node:zlib";
|
|
15
16
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -17,8 +18,9 @@ import { fileURLToPath } from "node:url";
|
|
|
17
18
|
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
18
19
|
import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
19
20
|
import { PluginBridge } from "./bridge.js";
|
|
21
|
+
import { LANE_TARGET_FIELD, PATTERN_LANE_IDS, PATTERN_LANE_SUFFIX, CC_LANES, PARAM_LANES, ccLaneMayLand, ccLaneIndex, ccLaneSlotField, ccLaneSlotOf, CC_OUT_CONFIG_KEYS, CC_OUT_SLOTS, isCcLane, isCcOutValueSuffix, isParamLane, normalizeCcLaneEvents, normalizeTargetLaneEvents, productHasLane, resolveLaneTarget, } from "./patternLanes.js";
|
|
20
22
|
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
21
|
-
import { normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
23
|
+
import { isFactoryPresetPath, nativePresetPath, normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
22
24
|
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
|
|
23
25
|
import { mcpProductProfile } from "./productProfile.js";
|
|
24
26
|
import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
|
|
@@ -78,7 +80,56 @@ function isMfxManifest(manifest) {
|
|
|
78
80
|
manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
|
|
79
81
|
manifest.pluginCode?.toLowerCase() === "amfx");
|
|
80
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
|
+
]);
|
|
81
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).`);
|
|
82
133
|
async function loadManifestForProductGuard() {
|
|
83
134
|
try {
|
|
84
135
|
return await loadManifest();
|
|
@@ -358,12 +409,6 @@ const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
|
358
409
|
// Preset listings grow with every installed pack; cap the unfiltered dump so it
|
|
359
410
|
// can't crowd out the agent's context (`search` narrows it).
|
|
360
411
|
const LIST_PRESETS_MAX = 400;
|
|
361
|
-
const PATTERN_LANES = {
|
|
362
|
-
note: "midiSequence",
|
|
363
|
-
pitch: "pitchSequence",
|
|
364
|
-
slice: "sliceSequence",
|
|
365
|
-
stretch: "stretchSequence",
|
|
366
|
-
};
|
|
367
412
|
const LFO_DIVISIONS = [
|
|
368
413
|
"16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
|
|
369
414
|
"1/2D", "1/2", "1/2T",
|
|
@@ -458,6 +503,44 @@ function formatNotModulatableError(paramId, index) {
|
|
|
458
503
|
const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
|
|
459
504
|
return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
|
|
460
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
|
+
}
|
|
461
544
|
function formatRangeError(param, value) {
|
|
462
545
|
const unit = param.suffix ? param.suffix : "unitless";
|
|
463
546
|
const traits = [
|
|
@@ -572,10 +655,13 @@ const server = new McpServer({
|
|
|
572
655
|
? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
|
|
573
656
|
"Use cells 1-4. Treat tool annotations as authoritative and use select_instance when several products are running."
|
|
574
657
|
: "PAM is an eight-cell sampler. " +
|
|
575
|
-
"Sequencer
|
|
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 " +
|
|
576
660
|
"window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
|
|
577
661
|
"feature flag. Treat tool annotations as authoritative and use select_instance when several " +
|
|
578
|
-
"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.",
|
|
579
665
|
});
|
|
580
666
|
// --- tool annotation policy ---------------------------------------------------
|
|
581
667
|
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
@@ -639,6 +725,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
639
725
|
reset_patterns: DI,
|
|
640
726
|
update_effect_config: WI,
|
|
641
727
|
update_cell_config: WI,
|
|
728
|
+
set_lane_target: WI,
|
|
642
729
|
set_cell_mute: WI,
|
|
643
730
|
set_master_effects: WI,
|
|
644
731
|
add_param_seq: W,
|
|
@@ -648,6 +735,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
648
735
|
set_drum_synth_wave: WI,
|
|
649
736
|
list_players: RO,
|
|
650
737
|
set_player: WI,
|
|
738
|
+
keep_player: W,
|
|
651
739
|
set_slices: WI,
|
|
652
740
|
set_free_sequencer: WI,
|
|
653
741
|
export_midi: W,
|
|
@@ -668,6 +756,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
668
756
|
trigger_midi_cc: W,
|
|
669
757
|
trigger_midi_program_change: W,
|
|
670
758
|
midi_recording: W,
|
|
759
|
+
session_take: W,
|
|
671
760
|
// Presets / variations
|
|
672
761
|
load_preset: DI, // replaces the working state (unsaved edits are lost)
|
|
673
762
|
get_preset_load_status: RO,
|
|
@@ -691,6 +780,8 @@ const TOOL_ANNOTATIONS = {
|
|
|
691
780
|
trim_sample: W, // restore_original_sample recovers the pre-edit file
|
|
692
781
|
reverse_sample: W,
|
|
693
782
|
gain_sample: W,
|
|
783
|
+
repitch_sample: W,
|
|
784
|
+
analyze_sample_pitch: RO,
|
|
694
785
|
normalize_sample: WI,
|
|
695
786
|
analyze_sample_tempo: WI, // dispatches results into state
|
|
696
787
|
resample_sample_to_bpm: WI,
|
|
@@ -826,7 +917,6 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
826
917
|
"cancel_queued_variation",
|
|
827
918
|
"update_variations",
|
|
828
919
|
"variation_hold",
|
|
829
|
-
"export_audio",
|
|
830
920
|
"update_settings",
|
|
831
921
|
"get_audio_device_setup",
|
|
832
922
|
"set_audio_device",
|
|
@@ -854,6 +944,8 @@ function mfxSurfaceText(value) {
|
|
|
854
944
|
.replaceAll("PAM", "MFX");
|
|
855
945
|
}
|
|
856
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];
|
|
857
949
|
// Zod wrappers are immutable, so narrowing a leaf means rebuilding the
|
|
858
950
|
// optional/default/array/union chain around it and re-attaching the description.
|
|
859
951
|
function rebuildSchema(schema, narrowLeaf) {
|
|
@@ -897,11 +989,13 @@ const MFX_CELL_FIELDS = new Set([
|
|
|
897
989
|
"sidechainFrom",
|
|
898
990
|
]);
|
|
899
991
|
// Enum members MFX has no surface for: Vary is removed from its modulation stack,
|
|
900
|
-
//
|
|
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.
|
|
901
994
|
const MFX_ENUM_DROPS = {
|
|
902
995
|
add_modulation: { type: ["vary"] },
|
|
996
|
+
update_modulation: { type: ["vary"] },
|
|
903
997
|
get_state: { section: ["vary", "loopers", "songMode"] },
|
|
904
|
-
|
|
998
|
+
set_midi_mapping: { target: ["looper", "resample", "vxRecord", "looperVariation"] },
|
|
905
999
|
};
|
|
906
1000
|
const MFX_KEPT_FIELDS = {
|
|
907
1001
|
update_cell_config: ["cellId", "sidechainFrom"],
|
|
@@ -910,6 +1004,8 @@ const MFX_DROPPED_FIELDS = {
|
|
|
910
1004
|
select_instance: ["product"],
|
|
911
1005
|
// MFX cells are effect slots — there is no synth/sampler tab to switch.
|
|
912
1006
|
capture_ui: ["instrumentView"],
|
|
1007
|
+
// MFX processes host input, which an offline render leaves silent.
|
|
1008
|
+
analyze_motion: ["bars"],
|
|
913
1009
|
};
|
|
914
1010
|
const MFX_TOOL_DESCRIPTIONS = {
|
|
915
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.",
|
|
@@ -920,24 +1016,62 @@ const MFX_TOOL_DESCRIPTIONS = {
|
|
|
920
1016
|
"html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
|
|
921
1017
|
"cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
|
|
922
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.",
|
|
923
1024
|
};
|
|
924
1025
|
const MFX_FIELD_DESCRIPTIONS = {
|
|
925
1026
|
capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
|
|
926
|
-
|
|
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
|
+
},
|
|
927
1044
|
add_modulation: {
|
|
928
1045
|
cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
|
|
929
1046
|
},
|
|
930
1047
|
};
|
|
931
1048
|
// Text whose subject is a tool this launcher does not register.
|
|
932
1049
|
function mfxDescriptionEdits(name, value) {
|
|
933
|
-
if (name === "export_audio")
|
|
934
|
-
return value
|
|
935
|
-
.replace("Set `mode` to bounce stems or the whole Song Mode arrangement.", "Set `mode` to bounce per-cell or per-bus stems.")
|
|
936
|
-
.replace(" · 'song-mode' (the Song Mode timeline)", "")
|
|
937
|
-
.replace(/\n\nNOTE on 'song-mode':[\s\S]*$/, "");
|
|
938
1050
|
if (name === "analyze_motion")
|
|
939
|
-
return value
|
|
940
|
-
"
|
|
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.");
|
|
941
1075
|
if (name === "set_master_effects")
|
|
942
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. ");
|
|
943
1077
|
if (name === "set_parameters")
|
|
@@ -986,7 +1120,14 @@ function mfxToolConfig(name, config) {
|
|
|
986
1120
|
if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
|
|
987
1121
|
return undefined;
|
|
988
1122
|
const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
|
|
989
|
-
|
|
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 });
|
|
990
1131
|
// Merge the central annotation policy. Inline annotations at the call site
|
|
991
1132
|
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
992
1133
|
const cfg = productConfig;
|
|
@@ -994,7 +1135,7 @@ function mfxToolConfig(name, config) {
|
|
|
994
1135
|
console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
995
1136
|
}
|
|
996
1137
|
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
997
|
-
return origRegisterTool(name, productConfig,
|
|
1138
|
+
return origRegisterTool(name, productConfig, productHandler);
|
|
998
1139
|
});
|
|
999
1140
|
}
|
|
1000
1141
|
async function callInternalTool(name, args) {
|
|
@@ -1560,7 +1701,7 @@ server.registerTool("analyze_motion", {
|
|
|
1560
1701
|
"a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
|
|
1561
1702
|
"identical to each other.\n\n" +
|
|
1562
1703
|
"Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
|
|
1563
|
-
"export first and leaves that render in PAM's Exports folder
|
|
1704
|
+
"export first and leaves that render in PAM's Exports folder (its path is in the result). Digital silence is " +
|
|
1564
1705
|
"reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
|
|
1565
1706
|
"silence in the standalone build.",
|
|
1566
1707
|
inputSchema: {
|
|
@@ -1572,35 +1713,34 @@ server.registerTool("analyze_motion", {
|
|
|
1572
1713
|
bpm: z
|
|
1573
1714
|
.number()
|
|
1574
1715
|
.optional()
|
|
1575
|
-
.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."),
|
|
1576
1717
|
},
|
|
1577
1718
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1578
1719
|
}, async ({ wavPath, bars, bpm }) => {
|
|
1579
1720
|
let path = wavPath;
|
|
1580
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;
|
|
1581
1727
|
if (!path) {
|
|
1582
1728
|
const check = await requireBridge();
|
|
1583
1729
|
if (check !== true)
|
|
1584
1730
|
return check;
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
mode: "main",
|
|
1591
|
-
durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
|
|
1592
|
-
bitDepth: 24,
|
|
1593
|
-
});
|
|
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.");
|
|
1594
1736
|
const outcome = await pollExportStatus();
|
|
1595
1737
|
if (outcome !== "success")
|
|
1596
1738
|
return error(`Render failed before analysis could run (export ${outcome}).`);
|
|
1597
|
-
|
|
1598
|
-
const
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}
|
|
1603
|
-
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;
|
|
1604
1744
|
}
|
|
1605
1745
|
else if (effectiveBpm === undefined) {
|
|
1606
1746
|
const check = await requireBridge();
|
|
@@ -1612,7 +1752,7 @@ server.registerTool("analyze_motion", {
|
|
|
1612
1752
|
}
|
|
1613
1753
|
let metrics;
|
|
1614
1754
|
try {
|
|
1615
|
-
metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
|
|
1755
|
+
metrics = analyzeMotion(decodeWav(await readFile(path)), barSeconds ?? (effectiveBpm ? (60 / effectiveBpm) * 4 : undefined));
|
|
1616
1756
|
}
|
|
1617
1757
|
catch (e) {
|
|
1618
1758
|
return error(`Could not analyze ${path}: ${e.message}`);
|
|
@@ -1745,69 +1885,6 @@ server.registerTool("variation_distinctness", {
|
|
|
1745
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.",
|
|
1746
1886
|
});
|
|
1747
1887
|
});
|
|
1748
|
-
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
1749
|
-
const KV_OPTS_STANDARD = {
|
|
1750
|
-
shouldDispatch: true,
|
|
1751
|
-
syncKVResources: true,
|
|
1752
|
-
syncConfigResources: true,
|
|
1753
|
-
resyncKVAfterDispatch: true,
|
|
1754
|
-
updateParameters: true,
|
|
1755
|
-
broadcastFollowers: true,
|
|
1756
|
-
};
|
|
1757
|
-
// Modulation update — same as standard but no follower broadcast.
|
|
1758
|
-
const KV_OPTS_MOD = {
|
|
1759
|
-
shouldDispatch: true,
|
|
1760
|
-
syncKVResources: true,
|
|
1761
|
-
syncConfigResources: true,
|
|
1762
|
-
resyncKVAfterDispatch: true,
|
|
1763
|
-
updateParameters: true,
|
|
1764
|
-
broadcastFollowers: false,
|
|
1765
|
-
};
|
|
1766
|
-
// Pattern/sequencer update — KV-only, no graph rebuild, no param updates.
|
|
1767
|
-
const KV_OPTS_PATTERN = {
|
|
1768
|
-
shouldDispatch: true,
|
|
1769
|
-
syncKVResources: true,
|
|
1770
|
-
syncConfigResources: false,
|
|
1771
|
-
skipFileLoadingIfUnchanged: true,
|
|
1772
|
-
resyncKVAfterDispatch: false,
|
|
1773
|
-
updateParameters: false,
|
|
1774
|
-
broadcastFollowers: false,
|
|
1775
|
-
};
|
|
1776
|
-
// Master-chain config — KV + config-resource sync, NO graph dispatch. Every
|
|
1777
|
-
// master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
|
|
1778
|
-
// limiter, EQ/width) is live-polled from master_config by its native node, so
|
|
1779
|
-
// a rebuild here is pure dropout risk (the plan's no-rebuild contract).
|
|
1780
|
-
// Mirrors interop.ts updateMasterConfig's options.
|
|
1781
|
-
const KV_OPTS_MASTER_CONFIG = {
|
|
1782
|
-
shouldDispatch: false,
|
|
1783
|
-
syncKVResources: true,
|
|
1784
|
-
syncConfigResources: true,
|
|
1785
|
-
skipFileLoadingIfUnchanged: true,
|
|
1786
|
-
resyncKVAfterDispatch: false,
|
|
1787
|
-
updateParameters: true,
|
|
1788
|
-
broadcastFollowers: false,
|
|
1789
|
-
};
|
|
1790
|
-
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
1791
|
-
const KV_OPTS_EFFECT_CONFIG = {
|
|
1792
|
-
shouldDispatch: false,
|
|
1793
|
-
syncKVResources: true,
|
|
1794
|
-
syncConfigResources: true,
|
|
1795
|
-
skipFileLoadingIfUnchanged: true,
|
|
1796
|
-
resyncKVAfterDispatch: false,
|
|
1797
|
-
updateParameters: true,
|
|
1798
|
-
broadcastFollowers: true,
|
|
1799
|
-
};
|
|
1800
|
-
// Cell config update — KV-only, syncs cell config resources, no graph dispatch.
|
|
1801
|
-
// Matches interop.ts updateCellConfig() options.
|
|
1802
|
-
const KV_OPTS_CELL_CONFIG = {
|
|
1803
|
-
shouldDispatch: false,
|
|
1804
|
-
syncKVResources: true,
|
|
1805
|
-
syncConfigResources: true,
|
|
1806
|
-
skipFileLoadingIfUnchanged: true,
|
|
1807
|
-
resyncKVAfterDispatch: false,
|
|
1808
|
-
updateParameters: true,
|
|
1809
|
-
broadcastFollowers: false,
|
|
1810
|
-
};
|
|
1811
1888
|
async function applyKv(delta, options, directNodeOps) {
|
|
1812
1889
|
const payload = { delta, options };
|
|
1813
1890
|
if (directNodeOps)
|
|
@@ -1917,7 +1994,7 @@ server.registerTool("get_design_overview", {
|
|
|
1917
1994
|
return json({
|
|
1918
1995
|
product: manifest.name,
|
|
1919
1996
|
bpm: state.bpm ?? transport.bpm,
|
|
1920
|
-
playing: transport.
|
|
1997
|
+
playing: transport.isPlaying,
|
|
1921
1998
|
mix: state.mfx_mix,
|
|
1922
1999
|
routing: state.mfx_routing,
|
|
1923
2000
|
edgeGains: state.mfx_edge_gains,
|
|
@@ -1956,10 +2033,14 @@ server.registerTool("get_design_overview", {
|
|
|
1956
2033
|
synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
|
|
1957
2034
|
player: describeCellPlayer(id, cellState, state),
|
|
1958
2035
|
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1959
|
-
patternEvents: Object.fromEntries(Object.entries(
|
|
2036
|
+
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANE_SUFFIX).map(([lane, suffix]) => [
|
|
1960
2037
|
lane,
|
|
1961
2038
|
countEvents(state[`${prefix}${suffix}`]),
|
|
1962
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
|
+
})),
|
|
1963
2044
|
modulation,
|
|
1964
2045
|
mixer: {
|
|
1965
2046
|
volume: state[`${prefix}volume`],
|
|
@@ -1971,7 +2052,7 @@ server.registerTool("get_design_overview", {
|
|
|
1971
2052
|
return json({
|
|
1972
2053
|
product: manifest.name,
|
|
1973
2054
|
bpm: state.bpm ?? transport.bpm,
|
|
1974
|
-
playing: transport.
|
|
2055
|
+
playing: transport.isPlaying,
|
|
1975
2056
|
masterFx: {
|
|
1976
2057
|
compressorEnabled: state.masterCompressorEnabled,
|
|
1977
2058
|
limiterEnabled: state.masterLimiterEnabled,
|
|
@@ -2067,14 +2148,7 @@ server.registerTool("set_parameters", {
|
|
|
2067
2148
|
}
|
|
2068
2149
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
2069
2150
|
delta,
|
|
2070
|
-
options:
|
|
2071
|
-
shouldDispatch: true,
|
|
2072
|
-
syncKVResources: true,
|
|
2073
|
-
syncConfigResources: true,
|
|
2074
|
-
resyncKVAfterDispatch: true,
|
|
2075
|
-
updateParameters: true,
|
|
2076
|
-
broadcastFollowers: true,
|
|
2077
|
-
},
|
|
2151
|
+
options: STATE_UPDATE_KV_OPTIONS,
|
|
2078
2152
|
});
|
|
2079
2153
|
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
|
|
2080
2154
|
(comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
|
|
@@ -2176,7 +2250,7 @@ server.registerTool("list_models", {
|
|
|
2176
2250
|
});
|
|
2177
2251
|
server.registerTool("generate_sample", {
|
|
2178
2252
|
title: "Generate Sample (AI)",
|
|
2179
|
-
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
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.",
|
|
2180
2254
|
inputSchema: {
|
|
2181
2255
|
category: z
|
|
2182
2256
|
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
@@ -2185,13 +2259,8 @@ server.registerTool("generate_sample", {
|
|
|
2185
2259
|
durationSeconds: z
|
|
2186
2260
|
.number()
|
|
2187
2261
|
.min(0.1)
|
|
2188
|
-
.max(
|
|
2189
|
-
.describe("Output length in seconds (
|
|
2190
|
-
mode: z
|
|
2191
|
-
.enum(["text", "inpaint", "continuation"])
|
|
2192
|
-
.optional()
|
|
2193
|
-
.default("text")
|
|
2194
|
-
.describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
|
|
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."),
|
|
2195
2264
|
cellId: z
|
|
2196
2265
|
.number()
|
|
2197
2266
|
.int()
|
|
@@ -2200,11 +2269,6 @@ server.registerTool("generate_sample", {
|
|
|
2200
2269
|
.optional()
|
|
2201
2270
|
.describe("If provided, load result directly into this cell."),
|
|
2202
2271
|
seed: z.number().int().optional().describe("Deterministic seed. Random if omitted."),
|
|
2203
|
-
refAudioPath: z.string().optional().describe("Required for inpaint/continuation. Absolute path to a WAV."),
|
|
2204
|
-
maskSpec: z
|
|
2205
|
-
.string()
|
|
2206
|
-
.optional()
|
|
2207
|
-
.describe('Mask spec for inpaint mode: "tail:0.5" or "transient:0-0.05" or "full".'),
|
|
2208
2272
|
},
|
|
2209
2273
|
}, async (args) => {
|
|
2210
2274
|
const ok = await requireBridge();
|
|
@@ -2220,7 +2284,7 @@ server.registerTool("generate_sample", {
|
|
|
2220
2284
|
});
|
|
2221
2285
|
server.registerTool("generate_kit", {
|
|
2222
2286
|
title: "Generate Kit (AI)",
|
|
2223
|
-
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).",
|
|
2224
2288
|
inputSchema: {
|
|
2225
2289
|
prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
|
|
2226
2290
|
cells: z
|
|
@@ -2319,7 +2383,7 @@ server.registerTool("workbench_set_state", {
|
|
|
2319
2383
|
server.registerTool("workbench_render", {
|
|
2320
2384
|
title: "Workbench: Render",
|
|
2321
2385
|
description: WORKBENCH_PREREQ +
|
|
2322
|
-
"Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist).
|
|
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).",
|
|
2323
2387
|
inputSchema: {
|
|
2324
2388
|
...WORKBENCH_STATE_FIELDS,
|
|
2325
2389
|
sourcePath: z.string().optional()
|
|
@@ -2506,11 +2570,9 @@ server.registerTool("add_modulation", {
|
|
|
2506
2570
|
},
|
|
2507
2571
|
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
2508
2572
|
const index = await getParameterIndex();
|
|
2509
|
-
const param =
|
|
2510
|
-
if (
|
|
2511
|
-
return error(
|
|
2512
|
-
if (!param.isModulatable)
|
|
2513
|
-
return error(formatNotModulatableError(paramId, index));
|
|
2573
|
+
const param = await resolveModulationTarget(paramId, index);
|
|
2574
|
+
if (typeof param === "string")
|
|
2575
|
+
return error(param);
|
|
2514
2576
|
const check = await requireBridgeForCells(cellId);
|
|
2515
2577
|
if (check !== true)
|
|
2516
2578
|
return check;
|
|
@@ -2536,34 +2598,42 @@ server.registerTool("add_modulation", {
|
|
|
2536
2598
|
}
|
|
2537
2599
|
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
2538
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
|
+
}
|
|
2539
2614
|
server.registerTool("update_pattern", {
|
|
2540
2615
|
title: "Update Pattern",
|
|
2541
|
-
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.",
|
|
2542
2620
|
inputSchema: {
|
|
2543
2621
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2544
|
-
type: z
|
|
2545
|
-
.enum(["note", "pitch", "slice", "stretch"])
|
|
2546
|
-
.describe("Pattern lane type."),
|
|
2622
|
+
type: z.enum(PATTERN_LANE_IDS).describe("Pattern lane."),
|
|
2547
2623
|
sequence: z
|
|
2548
2624
|
.array(z.any())
|
|
2549
2625
|
.describe("Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. " +
|
|
2550
|
-
"For
|
|
2626
|
+
"For value and CC lanes: [{time, value, duration, prob?}] (CC values 0-127; times and durations in beats)."),
|
|
2551
2627
|
},
|
|
2552
2628
|
}, async ({ cellId, type, sequence }) => {
|
|
2553
2629
|
const check = await requireBridgeForCells(cellId);
|
|
2554
2630
|
if (check !== true)
|
|
2555
2631
|
return check;
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
slice: "sliceSequence",
|
|
2562
|
-
stretch: "stretchSequence",
|
|
2563
|
-
};
|
|
2564
|
-
const suffix = keyMap[type];
|
|
2565
|
-
if (!suffix)
|
|
2566
|
-
return error(`Unknown pattern type: ${type}`);
|
|
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];
|
|
2567
2637
|
// For note sequences, fill in required defaults that PAM's sequencer expects
|
|
2568
2638
|
let normalizedSequence = sequence;
|
|
2569
2639
|
if (suffix === "midiSequence") {
|
|
@@ -2586,22 +2656,79 @@ server.registerTool("update_pattern", {
|
|
|
2586
2656
|
value: evt.value ?? evt.velocity ?? 127,
|
|
2587
2657
|
}));
|
|
2588
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
|
+
}
|
|
2589
2670
|
const stateKey = `cell${cellId}_${suffix}`;
|
|
2590
2671
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
2591
2672
|
delta: { [stateKey]: normalizedSequence, __forceRender__: true },
|
|
2592
|
-
options:
|
|
2593
|
-
shouldDispatch: true,
|
|
2594
|
-
syncKVResources: true,
|
|
2595
|
-
syncConfigResources: false,
|
|
2596
|
-
skipFileLoadingIfUnchanged: true,
|
|
2597
|
-
resyncKVAfterDispatch: false,
|
|
2598
|
-
updateParameters: false,
|
|
2599
|
-
broadcastFollowers: false,
|
|
2600
|
-
},
|
|
2673
|
+
options: PATTERN_EDIT_KV_OPTIONS,
|
|
2601
2674
|
directNodeOps: [{ key: stateKey, property: "sequence", value: normalizedSequence }],
|
|
2602
2675
|
});
|
|
2603
2676
|
return text(`Updated ${type} pattern for cell ${cellId} (${normalizedSequence.length} events).`);
|
|
2604
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
|
+
});
|
|
2605
2732
|
server.registerTool("randomize", {
|
|
2606
2733
|
title: "Randomize",
|
|
2607
2734
|
description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
|
|
@@ -2723,10 +2850,8 @@ server.registerTool("randomize_samples", {
|
|
|
2723
2850
|
let pool = allowedSamplePaths;
|
|
2724
2851
|
let diversifyNote = "";
|
|
2725
2852
|
if (!pool && diversify && diversify > 0) {
|
|
2726
|
-
const locations =
|
|
2727
|
-
const roots =
|
|
2728
|
-
.filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
|
|
2729
|
-
.map(([, v]) => v);
|
|
2853
|
+
const locations = await defaultLocationPaths();
|
|
2854
|
+
const roots = ["User Samples", "Factory Samples"].flatMap((name) => locations.get(name) ?? []);
|
|
2730
2855
|
if (roots.length) {
|
|
2731
2856
|
const byFolder = await collectLibraryByFolder(roots);
|
|
2732
2857
|
const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
|
|
@@ -2864,8 +2989,10 @@ server.registerTool("get_mix_analysis", {
|
|
|
2864
2989
|
title: "Get Per-Cell Mix Analysis",
|
|
2865
2990
|
description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
|
|
2866
2991
|
"true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
|
|
2867
|
-
"spectrum per cell, plus the full-mix master block.
|
|
2868
|
-
"
|
|
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 " +
|
|
2869
2996
|
"playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
|
|
2870
2997
|
inputSchema: {
|
|
2871
2998
|
refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
|
|
@@ -2986,7 +3113,7 @@ server.registerTool("suggest_mix", {
|
|
|
2986
3113
|
? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
|
|
2987
3114
|
: {};
|
|
2988
3115
|
if (apply && suggestion.moves.length > 0) {
|
|
2989
|
-
await applyKv(suggestion.flat,
|
|
3116
|
+
await applyKv(suggestion.flat, STATE_UPDATE_KV_OPTIONS);
|
|
2990
3117
|
if (!analysis.live)
|
|
2991
3118
|
lastAppliedMixEpoch = analysis.epoch;
|
|
2992
3119
|
return json({ ...suggestion, ...parkedNote, applied: true });
|
|
@@ -3070,7 +3197,7 @@ server.registerTool("save_preset", {
|
|
|
3070
3197
|
}
|
|
3071
3198
|
const metadata = { name, factory: false };
|
|
3072
3199
|
if (path !== undefined)
|
|
3073
|
-
metadata.path = path;
|
|
3200
|
+
metadata.path = nativePresetPath(path);
|
|
3074
3201
|
for (const [k, v] of Object.entries(extras)) {
|
|
3075
3202
|
if (v !== undefined)
|
|
3076
3203
|
metadata[k] = v;
|
|
@@ -3169,7 +3296,7 @@ server.registerTool("save_variation", {
|
|
|
3169
3296
|
let resultSuffix = "";
|
|
3170
3297
|
const destinationPath = normalizePresetLookupPath(diskPath);
|
|
3171
3298
|
if (!name && !destinationPath.startsWith("Factory/")) {
|
|
3172
|
-
metadata.path = destinationPath;
|
|
3299
|
+
metadata.path = nativePresetPath(destinationPath);
|
|
3173
3300
|
}
|
|
3174
3301
|
else if (sourcePath.startsWith("Factory/")) {
|
|
3175
3302
|
// Cannot write back to factory location; savePreset will create User/{name}.preset
|
|
@@ -3280,6 +3407,9 @@ server.registerTool("trim_sample", {
|
|
|
3280
3407
|
const check = await requireBridgeForCells(cellId);
|
|
3281
3408
|
if (check !== true)
|
|
3282
3409
|
return check;
|
|
3410
|
+
const loaded = await requireCellSample(cellId);
|
|
3411
|
+
if (loaded !== true)
|
|
3412
|
+
return loaded;
|
|
3283
3413
|
await bridge.send("trimSampleForCell", cellId);
|
|
3284
3414
|
return text(`Trimmed sample in cell ${cellId}.`);
|
|
3285
3415
|
});
|
|
@@ -3293,6 +3423,9 @@ server.registerTool("reverse_sample", {
|
|
|
3293
3423
|
const check = await requireBridgeForCells(cellId);
|
|
3294
3424
|
if (check !== true)
|
|
3295
3425
|
return check;
|
|
3426
|
+
const loaded = await requireCellSample(cellId);
|
|
3427
|
+
if (loaded !== true)
|
|
3428
|
+
return loaded;
|
|
3296
3429
|
await bridge.send("reverseSampleForCell", cellId);
|
|
3297
3430
|
return text(`Reversed sample in cell ${cellId}.`);
|
|
3298
3431
|
});
|
|
@@ -3342,6 +3475,54 @@ server.registerTool("connection_status", {
|
|
|
3342
3475
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
3343
3476
|
});
|
|
3344
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
|
+
}
|
|
3345
3526
|
server.registerTool("situation", {
|
|
3346
3527
|
title: "Situation",
|
|
3347
3528
|
description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
|
|
@@ -3359,21 +3540,34 @@ server.registerTool("situation", {
|
|
|
3359
3540
|
let build;
|
|
3360
3541
|
if (instance?.pid) {
|
|
3361
3542
|
try {
|
|
3362
|
-
const
|
|
3543
|
+
const processExecutable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
|
|
3363
3544
|
encoding: "utf8",
|
|
3364
3545
|
}).trim();
|
|
3365
|
-
if (
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
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
|
+
}
|
|
3370
3564
|
try {
|
|
3371
|
-
|
|
3565
|
+
if (executable)
|
|
3566
|
+
build.executableMtime = statSync(executable).mtime.toISOString();
|
|
3372
3567
|
}
|
|
3373
3568
|
catch {
|
|
3374
3569
|
/* executable path unreadable from this process */
|
|
3375
3570
|
}
|
|
3376
|
-
const projectRoot = process.env.PAM_PROJECT_ROOT;
|
|
3377
3571
|
if (projectRoot && typeof build.executableMtime === "string") {
|
|
3378
3572
|
try {
|
|
3379
3573
|
const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
|
|
@@ -3429,7 +3623,7 @@ server.registerTool("situation", {
|
|
|
3429
3623
|
live = {
|
|
3430
3624
|
activePreset: state.activePresetPath,
|
|
3431
3625
|
bpm: state.bpm ?? transport.bpm,
|
|
3432
|
-
playing: transport.
|
|
3626
|
+
playing: transport.isPlaying,
|
|
3433
3627
|
cells,
|
|
3434
3628
|
...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
|
|
3435
3629
|
};
|
|
@@ -3537,6 +3731,30 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
|
3537
3731
|
}
|
|
3538
3732
|
// Walk the library and return audio files grouped by their parent folder.
|
|
3539
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
|
+
}
|
|
3540
3758
|
async function collectLibraryByFolder(roots, maxDirReads = 120) {
|
|
3541
3759
|
const byFolder = new Map();
|
|
3542
3760
|
const queue = roots.map((path) => ({ path, depth: 0 }));
|
|
@@ -3721,8 +3939,11 @@ server.registerTool("export_preset", {
|
|
|
3721
3939
|
const check = await requireBridge();
|
|
3722
3940
|
if (check !== true)
|
|
3723
3941
|
return check;
|
|
3724
|
-
await bridge.send("exportPresetWithSamples", presetPath, outputFolderName);
|
|
3725
|
-
|
|
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`);
|
|
3726
3947
|
});
|
|
3727
3948
|
server.registerTool("import_preset", {
|
|
3728
3949
|
title: "Import Preset",
|
|
@@ -3901,14 +4122,15 @@ async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 25
|
|
|
3901
4122
|
if (ack && ack.requestId === requestId) {
|
|
3902
4123
|
const minted = ack.result?.[ackIdKey];
|
|
3903
4124
|
if (typeof minted === "string" && minted.length > 0)
|
|
3904
|
-
return minted;
|
|
3905
|
-
|
|
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" } : {};
|
|
3906
4128
|
}
|
|
3907
4129
|
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
3908
4130
|
if (found)
|
|
3909
|
-
return found;
|
|
4131
|
+
return { id: found };
|
|
3910
4132
|
}
|
|
3911
|
-
return
|
|
4133
|
+
return {};
|
|
3912
4134
|
}
|
|
3913
4135
|
server.registerTool("get_video_state", {
|
|
3914
4136
|
title: "Get Video State",
|
|
@@ -4064,7 +4286,9 @@ server.registerTool("add_video_layer", {
|
|
|
4064
4286
|
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
4065
4287
|
const requestId = mintVideoRequestId();
|
|
4066
4288
|
await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
|
|
4067
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
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}`);
|
|
4068
4292
|
return text(id
|
|
4069
4293
|
? `Added ${kind} layer: ${id}`
|
|
4070
4294
|
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
@@ -4075,7 +4299,8 @@ server.registerTool("update_video_layer", {
|
|
|
4075
4299
|
"(partial) against the current transform; other fields replace. Common: opacity " +
|
|
4076
4300
|
"(0..1), blendMode (normal|add|multiply|screen), enabled, name, transform " +
|
|
4077
4301
|
"{x,y(0..1),scale,rotation(rad),anchor}. Kind-specific via `fields`: text layers " +
|
|
4078
|
-
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath,
|
|
4302
|
+
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath (loads the file, as " +
|
|
4303
|
+
"set_video_layer_source does),fit}; " +
|
|
4079
4304
|
"camera {deviceId,mirror,fit}; drawing {paintColor,paintWidth}; model3d " +
|
|
4080
4305
|
"{lightingMode,lightIntensity,rotateRateX/Y/Z,...}. Use get_video_state to see a " +
|
|
4081
4306
|
"layer's current fields.",
|
|
@@ -4098,7 +4323,11 @@ server.registerTool("update_video_layer", {
|
|
|
4098
4323
|
const check = await requireBridge();
|
|
4099
4324
|
if (check !== true)
|
|
4100
4325
|
return check;
|
|
4101
|
-
|
|
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 };
|
|
4102
4331
|
if (opacity !== undefined)
|
|
4103
4332
|
patch.opacity = opacity;
|
|
4104
4333
|
if (blendMode !== undefined)
|
|
@@ -4109,6 +4338,10 @@ server.registerTool("update_video_layer", {
|
|
|
4109
4338
|
patch.name = name;
|
|
4110
4339
|
if (transform !== undefined)
|
|
4111
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}.`);
|
|
4112
4345
|
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
4113
4346
|
return json(result);
|
|
4114
4347
|
});
|
|
@@ -4187,7 +4420,9 @@ server.registerTool("add_video_fx", {
|
|
|
4187
4420
|
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
4188
4421
|
const requestId = mintVideoRequestId();
|
|
4189
4422
|
await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
|
|
4190
|
-
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}`);
|
|
4191
4426
|
// Apply param overrides now that the instance exists.
|
|
4192
4427
|
if (params && id)
|
|
4193
4428
|
for (const [param, value] of Object.entries(params))
|
|
@@ -4201,10 +4436,12 @@ server.registerTool("add_video_fx", {
|
|
|
4201
4436
|
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
4202
4437
|
const requestId = mintVideoRequestId();
|
|
4203
4438
|
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
|
|
4204
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => {
|
|
4439
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => {
|
|
4205
4440
|
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
4206
4441
|
return (l?.fx ?? []).map((f) => f.id);
|
|
4207
4442
|
}, "fxId");
|
|
4443
|
+
if (failure)
|
|
4444
|
+
return error(`add_video_fx failed: ${failure}`);
|
|
4208
4445
|
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
4209
4446
|
});
|
|
4210
4447
|
server.registerTool("set_video_fx", {
|
|
@@ -4245,10 +4482,18 @@ server.registerTool("set_video_fx", {
|
|
|
4245
4482
|
if (params)
|
|
4246
4483
|
for (const [p, v] of Object.entries(params))
|
|
4247
4484
|
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param: p, value: v }));
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
if (
|
|
4251
|
-
|
|
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 }));
|
|
4252
4497
|
}
|
|
4253
4498
|
if (results.length === 0)
|
|
4254
4499
|
return error("set_video_fx: nothing to set (provide param/value, params, enabled, or strength).");
|
|
@@ -4463,12 +4708,6 @@ server.registerTool("vx_variation", {
|
|
|
4463
4708
|
// ============================================================
|
|
4464
4709
|
// Composite cell-operation tools
|
|
4465
4710
|
// ============================================================
|
|
4466
|
-
const PATTERN_SUFFIXES = [
|
|
4467
|
-
"midiSequence",
|
|
4468
|
-
"pitchSequence",
|
|
4469
|
-
"sliceSequence",
|
|
4470
|
-
"stretchSequence",
|
|
4471
|
-
];
|
|
4472
4711
|
server.registerTool("reset_cell", {
|
|
4473
4712
|
title: "Reset Cell",
|
|
4474
4713
|
description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
|
|
@@ -4492,6 +4731,8 @@ server.registerTool("reset_cell", {
|
|
|
4492
4731
|
]);
|
|
4493
4732
|
const delta = {};
|
|
4494
4733
|
for (const p of cellParams) {
|
|
4734
|
+
if (isCcOutValueSuffix(p.paramId.replace(/^cell\d+_/, "")))
|
|
4735
|
+
continue;
|
|
4495
4736
|
if (p.defaultValue !== undefined)
|
|
4496
4737
|
delta[p.paramId] = p.defaultValue;
|
|
4497
4738
|
}
|
|
@@ -4501,12 +4742,14 @@ server.registerTool("reset_cell", {
|
|
|
4501
4742
|
const removedEnvs = envArray.length - filteredEnvs.length;
|
|
4502
4743
|
if (removedEnvs > 0)
|
|
4503
4744
|
delta.envelopes = filteredEnvs;
|
|
4504
|
-
await applyKv(delta,
|
|
4745
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4505
4746
|
return text(`Reset cell ${cellId} to defaults (${paramCount} params, removed ${removedEnvs} envelopes).`);
|
|
4506
4747
|
});
|
|
4507
4748
|
server.registerTool("copy_cell", {
|
|
4508
4749
|
title: "Copy Cell",
|
|
4509
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. " +
|
|
4510
4753
|
"Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
|
|
4511
4754
|
"sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
|
|
4512
4755
|
inputSchema: {
|
|
@@ -4533,10 +4776,33 @@ server.registerTool("copy_cell", {
|
|
|
4533
4776
|
const dstPrefix = `cell${targetCellId}_`;
|
|
4534
4777
|
const delta = {};
|
|
4535
4778
|
for (const [key, value] of Object.entries(state)) {
|
|
4536
|
-
if (key.startsWith(srcPrefix)) {
|
|
4779
|
+
if (key.startsWith(srcPrefix) && !isCcOutValueSuffix(key.slice(srcPrefix.length))) {
|
|
4537
4780
|
delta[dstPrefix + key.slice(srcPrefix.length)] = value;
|
|
4538
4781
|
}
|
|
4539
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 };
|
|
4540
4806
|
// Remap envelopes
|
|
4541
4807
|
const envelopes = state.envelopes || [];
|
|
4542
4808
|
if (envelopes.length > 0) {
|
|
@@ -4612,7 +4878,7 @@ server.registerTool("copy_cell", {
|
|
|
4612
4878
|
if (remapped.length > 0)
|
|
4613
4879
|
delta.paramSeqs = [...kept, ...remapped];
|
|
4614
4880
|
}
|
|
4615
|
-
await applyKv(delta,
|
|
4881
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4616
4882
|
return text(`Copied cell ${sourceCellId} → cell ${targetCellId} (${Object.keys(delta).length} keys).`);
|
|
4617
4883
|
});
|
|
4618
4884
|
server.registerTool("clear_modulations", {
|
|
@@ -4655,7 +4921,7 @@ server.registerTool("clear_modulations", {
|
|
|
4655
4921
|
if (Object.keys(delta).length === 0) {
|
|
4656
4922
|
return text(`No modulations found for cell ${cellId}.`);
|
|
4657
4923
|
}
|
|
4658
|
-
await applyKv(delta,
|
|
4924
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4659
4925
|
const removed = (lfos.length - filteredLfos.length) +
|
|
4660
4926
|
(vary.length - filteredVary.length) +
|
|
4661
4927
|
(envelopes.length - filteredEnvs.length);
|
|
@@ -4663,7 +4929,8 @@ server.registerTool("clear_modulations", {
|
|
|
4663
4929
|
});
|
|
4664
4930
|
server.registerTool("reset_patterns", {
|
|
4665
4931
|
title: "Reset Patterns",
|
|
4666
|
-
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.",
|
|
4667
4934
|
inputSchema: {
|
|
4668
4935
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4669
4936
|
},
|
|
@@ -4682,10 +4949,14 @@ server.registerTool("reset_patterns", {
|
|
|
4682
4949
|
return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
|
|
4683
4950
|
}
|
|
4684
4951
|
const delta = { __forceRender__: true };
|
|
4685
|
-
for (const
|
|
4686
|
-
|
|
4952
|
+
for (const lane of PATTERN_LANE_IDS) {
|
|
4953
|
+
if (!manifest || productHasLane(cellId, lane, manifest.stateVariables))
|
|
4954
|
+
delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`] = [];
|
|
4955
|
+
}
|
|
4956
|
+
if (!manifest || productHasLane(cellId, "paramLane1", manifest.stateVariables)) {
|
|
4957
|
+
delta.cells = { [cellId]: Object.fromEntries(PARAM_LANES.map((lane) => [LANE_TARGET_FIELD[lane], ""])) };
|
|
4687
4958
|
}
|
|
4688
|
-
await applyKv(delta,
|
|
4959
|
+
await applyKv(delta, PATTERN_EDIT_KV_OPTIONS);
|
|
4689
4960
|
return text(`Cleared all patterns for cell ${cellId}.`);
|
|
4690
4961
|
});
|
|
4691
4962
|
server.registerTool("update_effect_config", {
|
|
@@ -4753,7 +5024,7 @@ server.registerTool("update_effect_config", {
|
|
|
4753
5024
|
if (Object.keys(delta).length === 0) {
|
|
4754
5025
|
return error("At least one effect flag must be provided.");
|
|
4755
5026
|
}
|
|
4756
|
-
await applyKv(delta,
|
|
5027
|
+
await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
|
|
4757
5028
|
const summary = Object.entries(delta)
|
|
4758
5029
|
.map(([k, v]) => `${k}=${v}`)
|
|
4759
5030
|
.join(", ");
|
|
@@ -4763,7 +5034,7 @@ server.registerTool("update_effect_config", {
|
|
|
4763
5034
|
// unreachable through set_parameters (numbers only) — an agent could shape an MFX
|
|
4764
5035
|
// cell but never patch one to another, which is the whole product.
|
|
4765
5036
|
//
|
|
4766
|
-
// Validation mirrors parseRoutingEdges (interface/src/
|
|
5037
|
+
// Validation mirrors parseRoutingEdges (interface/src/utils/mfxRouting.ts): the
|
|
4767
5038
|
// plugin re-validates on receipt, so this pass exists to fail loudly here rather
|
|
4768
5039
|
// than have a typo silently drop a cable.
|
|
4769
5040
|
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
|
|
@@ -4858,7 +5129,7 @@ server.registerTool("set_mfx_routing", {
|
|
|
4858
5129
|
return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
|
|
4859
5130
|
}
|
|
4860
5131
|
// mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
|
|
4861
|
-
await applyKv({ mfx_routing: validated.edges },
|
|
5132
|
+
await applyKv({ mfx_routing: validated.edges }, STATE_UPDATE_KV_OPTIONS);
|
|
4862
5133
|
const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
|
|
4863
5134
|
return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
|
|
4864
5135
|
});
|
|
@@ -4895,7 +5166,8 @@ server.registerTool("set_mfx_edge_gain", {
|
|
|
4895
5166
|
return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
|
|
4896
5167
|
const key = `${from}__${to}`;
|
|
4897
5168
|
gains[key] = gain;
|
|
4898
|
-
|
|
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);
|
|
4899
5171
|
return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
|
|
4900
5172
|
});
|
|
4901
5173
|
const MFX_COMBO_TARGET_INPUT = z.object({
|
|
@@ -4954,7 +5226,7 @@ server.registerTool("configure_mfx_combo", {
|
|
|
4954
5226
|
const currentTargetValue = state[target.id];
|
|
4955
5227
|
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
4956
5228
|
}
|
|
4957
|
-
await applyKv(delta,
|
|
5229
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4958
5230
|
return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
|
|
4959
5231
|
}
|
|
4960
5232
|
const materialized = () => ({ ...stored, targets: [...combo.targets] });
|
|
@@ -5051,7 +5323,7 @@ server.registerTool("configure_mfx_combo", {
|
|
|
5051
5323
|
locked: next?.locked === 1 ? 1 : 0,
|
|
5052
5324
|
targets: Array.isArray(next?.targets) ? next.targets : null,
|
|
5053
5325
|
};
|
|
5054
|
-
await applyKv({ mfx_combos: { ...slots, [String(index)]: update } },
|
|
5326
|
+
await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, EFFECT_CONFIG_KV_OPTIONS);
|
|
5055
5327
|
return json({ combo: index, slot: update });
|
|
5056
5328
|
});
|
|
5057
5329
|
server.registerTool("set_effect_order", {
|
|
@@ -5111,15 +5383,15 @@ server.registerTool("set_effect_order", {
|
|
|
5111
5383
|
"To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
|
|
5112
5384
|
}
|
|
5113
5385
|
// effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
|
|
5114
|
-
await applyKv({ [key]: order },
|
|
5386
|
+
await applyKv({ [key]: order }, STATE_UPDATE_KV_OPTIONS);
|
|
5115
5387
|
return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
|
|
5116
5388
|
});
|
|
5117
5389
|
server.registerTool("update_cell_config", {
|
|
5118
5390
|
title: "Update Cell Config",
|
|
5119
|
-
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. " +
|
|
5120
5392
|
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
5121
5393
|
"Uses a lightweight KV path without graph rebuild. " +
|
|
5122
|
-
"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.",
|
|
5123
5395
|
inputSchema: {
|
|
5124
5396
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
5125
5397
|
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
@@ -5173,6 +5445,20 @@ server.registerTool("update_cell_config", {
|
|
|
5173
5445
|
.optional()
|
|
5174
5446
|
.describe("Sidechain compressor source. null=no sidechain, 0=external audio input, 1-8=cell output. " +
|
|
5175
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
|
+
])),
|
|
5176
5462
|
},
|
|
5177
5463
|
}, async ({ cellId, ...config }) => {
|
|
5178
5464
|
const check = await requireBridge();
|
|
@@ -5201,6 +5487,13 @@ server.registerTool("update_cell_config", {
|
|
|
5201
5487
|
`Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
|
|
5202
5488
|
}
|
|
5203
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
|
+
}
|
|
5204
5497
|
const cellConfig = {};
|
|
5205
5498
|
const flatDelta = {};
|
|
5206
5499
|
for (const [key, value] of Object.entries(config)) {
|
|
@@ -5228,7 +5521,8 @@ server.registerTool("update_cell_config", {
|
|
|
5228
5521
|
const delta = { ...flatDelta };
|
|
5229
5522
|
if (Object.keys(cellConfig).length > 0)
|
|
5230
5523
|
delta.cells = { [cellId]: cellConfig };
|
|
5231
|
-
|
|
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);
|
|
5232
5526
|
const summary = [
|
|
5233
5527
|
...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
|
|
5234
5528
|
...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
|
|
@@ -5264,10 +5558,10 @@ server.registerTool("set_cell_mute", {
|
|
|
5264
5558
|
}
|
|
5265
5559
|
// cell{N}_mute / cell{N}_solo are flat state vars projected to
|
|
5266
5560
|
// cell{N}_config.enabled by KVStateSyncManager — same options the UI's
|
|
5267
|
-
// applyStateDelta uses (
|
|
5561
|
+
// applyStateDelta uses (STATE_UPDATE_KV_OPTIONS); the projection needs
|
|
5268
5562
|
// syncConfigResources, no graph rebuild required but the standard path is
|
|
5269
5563
|
// the exact UI parity.
|
|
5270
|
-
await applyKv(delta,
|
|
5564
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
5271
5565
|
const summary = Object.entries(delta)
|
|
5272
5566
|
.map(([k, v]) => `${k}=${v}`)
|
|
5273
5567
|
.join(", ");
|
|
@@ -5319,6 +5613,14 @@ server.registerTool("set_master_effects", {
|
|
|
5319
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."),
|
|
5320
5614
|
masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
|
|
5321
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."),
|
|
5322
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)."),
|
|
5323
5625
|
masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
|
|
5324
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)."),
|
|
@@ -5390,7 +5692,7 @@ server.registerTool("set_master_effects", {
|
|
|
5390
5692
|
// No-dispatch path: every master key is live-polled from master_config
|
|
5391
5693
|
// (the full Punch surface included), so a graph rebuild here would only
|
|
5392
5694
|
// risk dropouts during automated mastering.
|
|
5393
|
-
await applyKv(delta,
|
|
5695
|
+
await applyKv(delta, MASTER_CONFIG_KV_OPTIONS);
|
|
5394
5696
|
const summary = Object.entries(delta)
|
|
5395
5697
|
.map(([k, v]) => `${k}=${v}`)
|
|
5396
5698
|
.join(", ");
|
|
@@ -5399,7 +5701,7 @@ server.registerTool("set_master_effects", {
|
|
|
5399
5701
|
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
5400
5702
|
// (Off = 0). The roster, the curated defaults and the engine-switch rule all come
|
|
5401
5703
|
// from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
|
|
5402
|
-
// and the manifest from, and interface/src/
|
|
5704
|
+
// and the manifest from, and interface/src/utils/drumSynth.ts derives its
|
|
5403
5705
|
// own tables from. The hand-copied table this replaced had drifted on Kick,
|
|
5404
5706
|
// Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
|
|
5405
5707
|
// positions where the plugin carries engine units.
|
|
@@ -5484,8 +5786,8 @@ server.registerTool("set_drum_synth", {
|
|
|
5484
5786
|
}
|
|
5485
5787
|
if (volume !== undefined)
|
|
5486
5788
|
delta[`cell${cellId}_synthVolume`] = volume;
|
|
5487
|
-
// synthEngine is a graph-render key → needs a graph dispatch (
|
|
5488
|
-
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);
|
|
5489
5791
|
const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
|
|
5490
5792
|
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
5491
5793
|
});
|
|
@@ -5616,7 +5918,7 @@ server.registerTool("set_player", {
|
|
|
5616
5918
|
patternMode: z
|
|
5617
5919
|
.enum(PATTERN_MODES)
|
|
5618
5920
|
.optional()
|
|
5619
|
-
.describe("'mask' (default): the player replaces the drawn pattern. 'keep': the drawn pattern keeps sounding and the player layers over it."),
|
|
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."),
|
|
5620
5922
|
listenMode: z
|
|
5621
5923
|
.enum(LISTEN_MODES)
|
|
5622
5924
|
.optional()
|
|
@@ -5777,10 +6079,35 @@ server.registerTool("set_player", {
|
|
|
5777
6079
|
}
|
|
5778
6080
|
if (Object.keys(cellsDelta).length === 0)
|
|
5779
6081
|
return text(lines.join("\n"));
|
|
5780
|
-
await applyKv({ ...flatDelta, cells: cellsDelta },
|
|
6082
|
+
await applyKv({ ...flatDelta, cells: cellsDelta }, CELL_CONFIG_KV_OPTIONS);
|
|
5781
6083
|
return text(`${lines.join("\n")}\n` +
|
|
5782
6084
|
"Players sound only while the transport runs; get_design_overview reports each cell's player.");
|
|
5783
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
|
+
});
|
|
5784
6111
|
/** A cell's sample as a readable file: absolute as given, else relative to a library root. */
|
|
5785
6112
|
async function resolveCellSampleFile(filePath) {
|
|
5786
6113
|
const expanded = expandHome(filePath);
|
|
@@ -5870,7 +6197,7 @@ server.registerTool("set_slices", {
|
|
|
5870
6197
|
};
|
|
5871
6198
|
if (currentKnob < 1 || currentKnob > sliceCount)
|
|
5872
6199
|
delta[`cell${cellId}_slice`] = 1;
|
|
5873
|
-
await applyKv(delta,
|
|
6200
|
+
await applyKv(delta, CELL_CONFIG_KV_OPTIONS);
|
|
5874
6201
|
let sequenceNote = "";
|
|
5875
6202
|
if (sequence) {
|
|
5876
6203
|
const transportRaw = await bridge.send("getTransport").catch(() => undefined);
|
|
@@ -5908,7 +6235,10 @@ server.registerTool("set_slices", {
|
|
|
5908
6235
|
[`cell${cellId}_slice`]: 1,
|
|
5909
6236
|
cells: { [cellId]: { modLatch: 1 } },
|
|
5910
6237
|
__forceRender__: true,
|
|
5911
|
-
},
|
|
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 });
|
|
5912
6242
|
sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
|
|
5913
6243
|
}
|
|
5914
6244
|
const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
|
|
@@ -5945,11 +6275,9 @@ server.registerTool("add_param_seq", {
|
|
|
5945
6275
|
if (check !== true)
|
|
5946
6276
|
return check;
|
|
5947
6277
|
const index = await getParameterIndex();
|
|
5948
|
-
const param =
|
|
5949
|
-
if (
|
|
5950
|
-
return error(
|
|
5951
|
-
if (!param.isModulatable)
|
|
5952
|
-
return error(formatNotModulatableError(paramId, index));
|
|
6278
|
+
const param = await resolveModulationTarget(paramId, index);
|
|
6279
|
+
if (typeof param === "string")
|
|
6280
|
+
return error(param);
|
|
5953
6281
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
5954
6282
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
5955
6283
|
const newSeq = {
|
|
@@ -5967,7 +6295,7 @@ server.registerTool("add_param_seq", {
|
|
|
5967
6295
|
stepMode,
|
|
5968
6296
|
};
|
|
5969
6297
|
paramSeqs.push(newSeq);
|
|
5970
|
-
await applyKv({ paramSeqs },
|
|
6298
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
5971
6299
|
return text(`Added paramSeq for ${paramId} (${newSeq.sequence.length} steps, strength ${strength}).`);
|
|
5972
6300
|
});
|
|
5973
6301
|
server.registerTool("update_param_seq", {
|
|
@@ -6010,7 +6338,7 @@ server.registerTool("update_param_seq", {
|
|
|
6010
6338
|
return error(`No paramSeq found for ${paramId}. Use add_param_seq first.`);
|
|
6011
6339
|
}
|
|
6012
6340
|
paramSeqs[idx] = { ...paramSeqs[idx], ...cleanUpdates };
|
|
6013
|
-
await applyKv({ paramSeqs },
|
|
6341
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
6014
6342
|
return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
|
|
6015
6343
|
});
|
|
6016
6344
|
server.registerTool("set_free_sequencer", {
|
|
@@ -6020,27 +6348,36 @@ server.registerTool("set_free_sequencer", {
|
|
|
6020
6348
|
"clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
|
|
6021
6349
|
"their own steps. An empty contour restores the legacy per-sequencer behaviour.",
|
|
6022
6350
|
inputSchema: {
|
|
6023
|
-
sequence: z
|
|
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."),
|
|
6024
6355
|
},
|
|
6025
6356
|
}, async ({ sequence }) => {
|
|
6026
6357
|
const check = await requireBridge();
|
|
6027
6358
|
if (check !== true)
|
|
6028
6359
|
return check;
|
|
6360
|
+
if (sequence.length === 1)
|
|
6361
|
+
return error("A contour needs 2..64 steps; pass [] to clear it.");
|
|
6029
6362
|
await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
|
|
6363
|
+
if (sequence.length === 0)
|
|
6364
|
+
return text("Free Sequencer contour cleared; mapped sequencers use their own steps.");
|
|
6030
6365
|
return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
|
|
6031
6366
|
});
|
|
6032
6367
|
server.registerTool("export_midi", {
|
|
6033
6368
|
title: "Export MIDI",
|
|
6034
|
-
description: "Write the cells'
|
|
6035
|
-
"one track per cell on MIDI channel = cell number,
|
|
6036
|
-
"
|
|
6037
|
-
"
|
|
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.",
|
|
6038
6375
|
inputSchema: {
|
|
6039
6376
|
filename: z.string().optional().describe("Base name without extension (default pam_midi_<epoch>)."),
|
|
6040
6377
|
cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes)."),
|
|
6041
6378
|
bars: z.number().min(1).max(64).optional().describe("Length in 4/4 bars (default: the longest included pattern cycle, at least 1)."),
|
|
6042
6379
|
mode: z.enum(["combined", "stems"]).optional().describe("'combined' (default): one multi-track file. 'stems': one file per cell."),
|
|
6043
|
-
outputDir: z.string().optional().describe("Directory to write into (default:
|
|
6380
|
+
outputDir: z.string().optional().describe("Directory to write into (default: PAM's Exports folder)."),
|
|
6044
6381
|
},
|
|
6045
6382
|
}, async ({ filename, cellIds, bars, mode, outputDir }) => {
|
|
6046
6383
|
const check = await requireBridgeForCells(cellIds);
|
|
@@ -6049,28 +6386,26 @@ server.registerTool("export_midi", {
|
|
|
6049
6386
|
const manifest = await loadManifestForProductGuard();
|
|
6050
6387
|
if (manifest && isMfxManifest(manifest))
|
|
6051
6388
|
return error("export_midi needs sampler cells with note patterns; MFX has none.");
|
|
6052
|
-
const [stateRaw, transportRaw,
|
|
6389
|
+
const [stateRaw, transportRaw, exportsDir] = await Promise.all([
|
|
6053
6390
|
bridge.send("getState"),
|
|
6054
6391
|
bridge.send("getTransport").catch(() => undefined),
|
|
6055
|
-
|
|
6392
|
+
exportsFolder(),
|
|
6056
6393
|
]);
|
|
6057
6394
|
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
6058
6395
|
const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
|
|
6059
|
-
|
|
6060
|
-
if (!dir) {
|
|
6061
|
-
if (Array.isArray(locations)) {
|
|
6062
|
-
const exportsEntry = locations.find((entry) => isRecord(entry) && /export/i.test(String(entry.name)));
|
|
6063
|
-
if (isRecord(exportsEntry) && typeof exportsEntry.path === "string")
|
|
6064
|
-
dir = exportsEntry.path;
|
|
6065
|
-
}
|
|
6066
|
-
else if (isRecord(locations)) {
|
|
6067
|
-
dir = [locations.exports, locations.exportsLocation, locations.Exports].find((v) => typeof v === "string" && v.length > 0);
|
|
6068
|
-
}
|
|
6069
|
-
}
|
|
6396
|
+
const dir = outputDir ? expandHome(outputDir) : exportsDir;
|
|
6070
6397
|
if (!dir)
|
|
6071
6398
|
return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
|
|
6072
6399
|
const maxCell = manifestMaxCellId(manifest) ?? 8;
|
|
6073
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);
|
|
6074
6409
|
const cells = ids
|
|
6075
6410
|
.map((id) => ({
|
|
6076
6411
|
id,
|
|
@@ -6078,9 +6413,9 @@ server.registerTool("export_midi", {
|
|
|
6078
6413
|
patternLength: state[`cell${id}_patternLength`],
|
|
6079
6414
|
speed: state[`cell${id}_patternSpeed`],
|
|
6080
6415
|
}))
|
|
6081
|
-
.filter((cell) => Array.isArray(cell.sequence) && cell.sequence.length > 0);
|
|
6416
|
+
.filter((cell) => (Array.isArray(cell.sequence) && cell.sequence.length > 0) || playerIds.includes(cell.id) || followerIds.includes(cell.id));
|
|
6082
6417
|
if (cells.length === 0)
|
|
6083
|
-
return error("No note patterns to export — the selected cells carry no notes.");
|
|
6418
|
+
return error("No note patterns to export — the selected cells carry no notes and run no Player.");
|
|
6084
6419
|
const cycleBars = cells.map((cell) => {
|
|
6085
6420
|
const length = Number(cell.patternLength);
|
|
6086
6421
|
const speedIndex = Math.round(Number(cell.speed));
|
|
@@ -6089,10 +6424,45 @@ server.registerTool("export_midi", {
|
|
|
6089
6424
|
});
|
|
6090
6425
|
const totalBars = bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars)));
|
|
6091
6426
|
const totalBeats = totalBars * 4;
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
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.`);
|
|
6096
6466
|
const base = (filename?.trim() || `pam_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
|
|
6097
6467
|
await mkdir(dir, { recursive: true });
|
|
6098
6468
|
const written = [];
|
|
@@ -6110,7 +6480,7 @@ server.registerTool("export_midi", {
|
|
|
6110
6480
|
}
|
|
6111
6481
|
const noteCount = tracks.reduce((sum, track) => sum + track.events.length, 0);
|
|
6112
6482
|
return text(`Exported ${noteCount} notes from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"} at ${bpm} BPM.\n` +
|
|
6113
|
-
written.map((path) => `File: ${path}`).join("\n"));
|
|
6483
|
+
[...written.map((path) => `File: ${path}`), ...notes].join("\n"));
|
|
6114
6484
|
});
|
|
6115
6485
|
server.registerTool("remove_param_seq", {
|
|
6116
6486
|
title: "Remove Parameter Sequencer",
|
|
@@ -6125,7 +6495,7 @@ server.registerTool("remove_param_seq", {
|
|
|
6125
6495
|
return check;
|
|
6126
6496
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
6127
6497
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
6128
|
-
await applyKv({ paramSeqs },
|
|
6498
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
6129
6499
|
return text(`Removed paramSeq for ${paramId}.`);
|
|
6130
6500
|
});
|
|
6131
6501
|
server.registerTool("normalize_sample", {
|
|
@@ -6138,6 +6508,9 @@ server.registerTool("normalize_sample", {
|
|
|
6138
6508
|
const check = await requireBridgeForCells(cellId);
|
|
6139
6509
|
if (check !== true)
|
|
6140
6510
|
return check;
|
|
6511
|
+
const loaded = await requireCellSample(cellId);
|
|
6512
|
+
if (loaded !== true)
|
|
6513
|
+
return loaded;
|
|
6141
6514
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
6142
6515
|
return text(`Normalized sample in cell ${cellId}.`);
|
|
6143
6516
|
});
|
|
@@ -6157,9 +6530,64 @@ server.registerTool("gain_sample", {
|
|
|
6157
6530
|
const check = await requireBridgeForCells(cellId);
|
|
6158
6531
|
if (check !== true)
|
|
6159
6532
|
return check;
|
|
6533
|
+
const loaded = await requireCellSample(cellId);
|
|
6534
|
+
if (loaded !== true)
|
|
6535
|
+
return loaded;
|
|
6160
6536
|
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
6161
6537
|
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
6162
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
|
+
});
|
|
6163
6591
|
// Poll the offline-render status flag until the export completes or times out.
|
|
6164
6592
|
async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
6165
6593
|
const t0 = Date.now();
|
|
@@ -6830,20 +7258,11 @@ if (!IS_MFX_SERVER)
|
|
|
6830
7258
|
// ============================================================
|
|
6831
7259
|
// TOOLS — Looper (state deltas)
|
|
6832
7260
|
// ============================================================
|
|
6833
|
-
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
6834
|
-
const KV_OPTS_LOOPER_STATE = {
|
|
6835
|
-
shouldDispatch: true,
|
|
6836
|
-
syncKVResources: true,
|
|
6837
|
-
syncConfigResources: true,
|
|
6838
|
-
resyncKVAfterDispatch: true,
|
|
6839
|
-
updateParameters: true,
|
|
6840
|
-
broadcastFollowers: true,
|
|
6841
|
-
};
|
|
6842
7261
|
async function applyLoopersDelta(loopers, extra) {
|
|
6843
7262
|
const delta = { loopers, ...(extra ?? {}) };
|
|
6844
7263
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
6845
7264
|
delta,
|
|
6846
|
-
options:
|
|
7265
|
+
options: STATE_UPDATE_KV_OPTIONS,
|
|
6847
7266
|
});
|
|
6848
7267
|
}
|
|
6849
7268
|
server.registerTool("arm_looper_track", {
|
|
@@ -7146,7 +7565,7 @@ server.registerTool("looper_settings", {
|
|
|
7146
7565
|
server.registerTool("looper_variation", {
|
|
7147
7566
|
title: "Looper Variation",
|
|
7148
7567
|
description: "Manage looper variation slots (clip-set snapshots). Operations: 'apply' (immediate), 'queue' (bar-synced), " +
|
|
7149
|
-
"'cancel', 'clear', 'setCurrent', 'save' (snapshot
|
|
7568
|
+
"'cancel', 'clear', 'setCurrent', 'save' (write a snapshot object from the `data` field into the slot).",
|
|
7150
7569
|
inputSchema: {
|
|
7151
7570
|
op: z
|
|
7152
7571
|
.enum(["apply", "queue", "cancel", "clear", "setCurrent", "save"])
|
|
@@ -7159,7 +7578,7 @@ server.registerTool("looper_variation", {
|
|
|
7159
7578
|
.describe("Variation slot 0-7 (required for apply/queue/clear/setCurrent/save)."),
|
|
7160
7579
|
intervalBars: z
|
|
7161
7580
|
.number()
|
|
7162
|
-
.min(0.
|
|
7581
|
+
.min(0.25)
|
|
7163
7582
|
.max(64)
|
|
7164
7583
|
.optional()
|
|
7165
7584
|
.describe("Bar interval for 'queue' (required for queue)."),
|
|
@@ -7167,7 +7586,7 @@ server.registerTool("looper_variation", {
|
|
|
7167
7586
|
data: z
|
|
7168
7587
|
.record(z.string(), z.unknown())
|
|
7169
7588
|
.optional()
|
|
7170
|
-
.describe("For 'save': the looper variation snapshot
|
|
7589
|
+
.describe("For 'save' (required): the looper variation snapshot object. Use op 'clear' to empty a slot."),
|
|
7171
7590
|
},
|
|
7172
7591
|
}, async (args) => {
|
|
7173
7592
|
const check = await requireBridge();
|
|
@@ -7204,10 +7623,10 @@ server.registerTool("looper_variation", {
|
|
|
7204
7623
|
case "save":
|
|
7205
7624
|
if (args.index === undefined)
|
|
7206
7625
|
return error("'save' requires index");
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
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}.`);
|
|
7211
7630
|
}
|
|
7212
7631
|
return text("No-op");
|
|
7213
7632
|
});
|
|
@@ -7216,12 +7635,18 @@ server.registerTool("looper_variation_sync", {
|
|
|
7216
7635
|
description: "Configure how looper variation switches synchronize to bar boundaries.",
|
|
7217
7636
|
inputSchema: {
|
|
7218
7637
|
barSyncEnabled: z.boolean().optional().describe("Toggle bar-sync mode."),
|
|
7219
|
-
intervalBars: z
|
|
7638
|
+
intervalBars: z
|
|
7639
|
+
.number()
|
|
7640
|
+
.optional()
|
|
7641
|
+
.describe(`Sync interval in bars: one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}.`),
|
|
7220
7642
|
},
|
|
7221
7643
|
}, async ({ barSyncEnabled, intervalBars }) => {
|
|
7222
7644
|
const check = await requireBridge();
|
|
7223
7645
|
if (check !== true)
|
|
7224
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
|
+
}
|
|
7225
7650
|
const updates = [];
|
|
7226
7651
|
if (typeof barSyncEnabled === "boolean") {
|
|
7227
7652
|
await bridge.send("setLooperVariationBarSyncEnabled", barSyncEnabled);
|
|
@@ -7244,7 +7669,7 @@ server.registerTool("queue_variation", {
|
|
|
7244
7669
|
"already-engaged queued-flip window only; a from-idle flip hard-switches).",
|
|
7245
7670
|
inputSchema: {
|
|
7246
7671
|
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
7247
|
-
intervalBars: z.number().min(0.
|
|
7672
|
+
intervalBars: z.number().min(0.25).max(64).describe("Bars before switching."),
|
|
7248
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."),
|
|
7249
7674
|
resetPhase: z
|
|
7250
7675
|
.boolean()
|
|
@@ -7346,14 +7771,25 @@ server.registerTool("variation_hold", {
|
|
|
7346
7771
|
if (check !== true)
|
|
7347
7772
|
return check;
|
|
7348
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 = {};
|
|
7349
7777
|
if (typeof enabled === "boolean") {
|
|
7350
7778
|
await bridge.send("setVariationHoldEnabled", enabled);
|
|
7779
|
+
persisted.variationHoldEnabled = enabled;
|
|
7351
7780
|
updates.push(`enabled=${enabled}`);
|
|
7352
7781
|
}
|
|
7353
7782
|
if (bindings) {
|
|
7354
|
-
|
|
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
|
+
}));
|
|
7355
7789
|
updates.push(`bindings=${bindings.length}`);
|
|
7356
7790
|
}
|
|
7791
|
+
if (Object.keys(persisted).length)
|
|
7792
|
+
await bridge.send("applySettingsUpdate", persisted);
|
|
7357
7793
|
if (allNotesOff) {
|
|
7358
7794
|
await bridge.send("variationHoldAllNotesOff");
|
|
7359
7795
|
updates.push("allNotesOff");
|
|
@@ -7440,6 +7876,21 @@ server.registerTool("midi_recording", {
|
|
|
7440
7876
|
}
|
|
7441
7877
|
return text("No-op");
|
|
7442
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
|
+
});
|
|
7443
7894
|
// ============================================================
|
|
7444
7895
|
// TOOLS — Modulation updates (LFO/Env/Vary/Macro/ParamSeq)
|
|
7445
7896
|
// ============================================================
|
|
@@ -7565,7 +8016,7 @@ server.registerTool("song_mode", {
|
|
|
7565
8016
|
sections: z
|
|
7566
8017
|
.array(z.object({
|
|
7567
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."),
|
|
7568
|
-
bars: z.number().
|
|
8019
|
+
bars: z.number().int().min(1).optional().describe("Section length in whole bars (default: auto from longest pattern)."),
|
|
7569
8020
|
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
7570
8021
|
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
7571
8022
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
@@ -8069,7 +8520,7 @@ server.registerTool("preview_slice", {
|
|
|
8069
8520
|
description: "Audition a single slice of a cell's sample.",
|
|
8070
8521
|
inputSchema: {
|
|
8071
8522
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8072
|
-
sliceNumber: z.number().min(
|
|
8523
|
+
sliceNumber: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
|
|
8073
8524
|
},
|
|
8074
8525
|
}, async ({ cellId, sliceNumber }) => {
|
|
8075
8526
|
const check = await requireBridgeForCells(cellId);
|
|
@@ -8097,12 +8548,15 @@ server.registerTool("resample_sample_to_bpm", {
|
|
|
8097
8548
|
.optional()
|
|
8098
8549
|
.describe("Target BPM (defaults to current project BPM)."),
|
|
8099
8550
|
},
|
|
8100
|
-
}, async (
|
|
8551
|
+
}, async ({ cellId, sourceBpm, targetBpm }) => {
|
|
8101
8552
|
const check = await requireBridge();
|
|
8102
8553
|
if (check !== true)
|
|
8103
8554
|
return check;
|
|
8104
|
-
await
|
|
8105
|
-
|
|
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.`);
|
|
8106
8560
|
});
|
|
8107
8561
|
server.registerTool("restore_original_sample", {
|
|
8108
8562
|
title: "Restore Original Sample",
|
|
@@ -8114,6 +8568,9 @@ server.registerTool("restore_original_sample", {
|
|
|
8114
8568
|
const check = await requireBridgeForCells(cellId);
|
|
8115
8569
|
if (check !== true)
|
|
8116
8570
|
return check;
|
|
8571
|
+
const loaded = await requireCellSample(cellId, "originalFilePath");
|
|
8572
|
+
if (loaded !== true)
|
|
8573
|
+
return loaded;
|
|
8117
8574
|
await bridge.send("restoreOriginalSample", { cellId });
|
|
8118
8575
|
return text(`Restored original sample for cell ${cellId}.`);
|
|
8119
8576
|
});
|
|
@@ -8136,7 +8593,7 @@ server.registerTool("export_slice", {
|
|
|
8136
8593
|
description: "Export a single slice of a cell's sample as a standalone WAV.",
|
|
8137
8594
|
inputSchema: {
|
|
8138
8595
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8139
|
-
sliceIndex: z.number().min(
|
|
8596
|
+
sliceIndex: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
|
|
8140
8597
|
},
|
|
8141
8598
|
}, async ({ cellId, sliceIndex }) => {
|
|
8142
8599
|
const check = await requireBridgeForCells(cellId);
|
|
@@ -8167,10 +8624,11 @@ server.registerTool("live_resample", {
|
|
|
8167
8624
|
.describe("For 'start': destination cell the captured clip lands in. Use an empty/scratch cell for a full-mix bounce."),
|
|
8168
8625
|
bars: z
|
|
8169
8626
|
.number()
|
|
8170
|
-
.
|
|
8627
|
+
.int()
|
|
8628
|
+
.min(1)
|
|
8171
8629
|
.max(64)
|
|
8172
8630
|
.optional()
|
|
8173
|
-
.describe("For 'start':
|
|
8631
|
+
.describe("For 'start': whole bars to capture (max 64; the capture also stops on the 'stop' op)."),
|
|
8174
8632
|
source: z
|
|
8175
8633
|
.enum(["cell", "input", "fullmix"])
|
|
8176
8634
|
.default("cell")
|
|
@@ -8433,15 +8891,20 @@ server.registerTool("list_loaded_plugins", {
|
|
|
8433
8891
|
title: "List Loaded Plugins",
|
|
8434
8892
|
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
8435
8893
|
"Each entry: cellId, name, manufacturer, format, " +
|
|
8436
|
-
"path, isInstrument, bypassed, and the plugin's
|
|
8437
|
-
"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.",
|
|
8438
8896
|
inputSchema: {},
|
|
8439
8897
|
}, async () => {
|
|
8440
8898
|
const check = await requireBridge();
|
|
8441
8899
|
if (check !== true)
|
|
8442
8900
|
return check;
|
|
8443
8901
|
const raw = await bridge.send("listLoadedPlugins");
|
|
8444
|
-
|
|
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));
|
|
8445
8908
|
});
|
|
8446
8909
|
server.registerTool("load_plugin", {
|
|
8447
8910
|
title: "Load Plugin Into Cell",
|
|
@@ -8614,7 +9077,7 @@ server.registerTool("configure_transport", {
|
|
|
8614
9077
|
server.registerTool("get_audio_device_setup", {
|
|
8615
9078
|
title: "Get Audio Device Setup",
|
|
8616
9079
|
description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
|
|
8617
|
-
"Returns
|
|
9080
|
+
"Returns {pluginHosted:true} inside a DAW, which owns the device config. Use the device IDs/names from here with set_midi_io.",
|
|
8618
9081
|
inputSchema: {},
|
|
8619
9082
|
annotations: { readOnlyHint: true },
|
|
8620
9083
|
}, async () => {
|
|
@@ -8622,6 +9085,9 @@ server.registerTool("get_audio_device_setup", {
|
|
|
8622
9085
|
if (check !== true)
|
|
8623
9086
|
return check;
|
|
8624
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
|
+
}
|
|
8625
9091
|
return json(setup);
|
|
8626
9092
|
});
|
|
8627
9093
|
server.registerTool("get_memory_report", {
|
|
@@ -8655,35 +9121,76 @@ server.registerTool("set_audio_device", {
|
|
|
8655
9121
|
const check = await requireBridge();
|
|
8656
9122
|
if (check !== true)
|
|
8657
9123
|
return check;
|
|
8658
|
-
await bridge.send("setAudioDevice",
|
|
9124
|
+
await bridge.send("setAudioDevice", deviceName);
|
|
8659
9125
|
return text(`Audio output device set to '${deviceName}'.`);
|
|
8660
9126
|
});
|
|
8661
9127
|
server.registerTool("set_midi_io", {
|
|
8662
9128
|
title: "Set MIDI I/O Devices",
|
|
8663
|
-
description: "Select MIDI input/output devices (standalone).
|
|
8664
|
-
"
|
|
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.",
|
|
8665
9132
|
inputSchema: {
|
|
8666
|
-
inputDeviceIds: z
|
|
8667
|
-
|
|
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."),
|
|
8668
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."),
|
|
8669
9144
|
},
|
|
8670
9145
|
}, async (args) => {
|
|
8671
9146
|
const check = await requireBridge();
|
|
8672
9147
|
if (check !== true)
|
|
8673
9148
|
return check;
|
|
8674
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);
|
|
8675
9175
|
if (args.inputDeviceIds !== undefined) {
|
|
8676
|
-
await bridge.send("setMidiInputDevices",
|
|
8677
|
-
applied.push(`inputs=${
|
|
9176
|
+
await bridge.send("setMidiInputDevices", inputs.ids);
|
|
9177
|
+
applied.push(`inputs=${inputs.ids.length}`);
|
|
8678
9178
|
}
|
|
8679
9179
|
if (args.outputDeviceId !== undefined) {
|
|
8680
|
-
await bridge.send("setMidiOutputDevice",
|
|
8681
|
-
applied.push(`output=${args.outputDeviceId}`);
|
|
9180
|
+
await bridge.send("setMidiOutputDevice", output.ids[0]);
|
|
9181
|
+
applied.push(`output=${args.outputDeviceId || "none"}`);
|
|
8682
9182
|
}
|
|
8683
9183
|
if (args.outputEnabled !== undefined) {
|
|
8684
9184
|
await bridge.send("setMidiOutputEnabled", args.outputEnabled);
|
|
8685
9185
|
applied.push(`outputEnabled=${args.outputEnabled}`);
|
|
8686
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
|
+
}
|
|
8687
9194
|
if (applied.length === 0)
|
|
8688
9195
|
return error("No MIDI I/O fields provided.");
|
|
8689
9196
|
return text(`MIDI I/O set: ${applied.join(", ")}.`);
|
|
@@ -8724,7 +9231,11 @@ server.registerTool("delete_preset", {
|
|
|
8724
9231
|
const check = await requireBridge();
|
|
8725
9232
|
if (check !== true)
|
|
8726
9233
|
return check;
|
|
8727
|
-
|
|
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}`);
|
|
8728
9239
|
return json(result);
|
|
8729
9240
|
});
|
|
8730
9241
|
server.registerTool("set_preset_factory_status", {
|
|
@@ -8738,7 +9249,7 @@ server.registerTool("set_preset_factory_status", {
|
|
|
8738
9249
|
const check = await requireBridge();
|
|
8739
9250
|
if (check !== true)
|
|
8740
9251
|
return check;
|
|
8741
|
-
const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
|
|
9252
|
+
const result = await bridge.send("togglePresetFactoryStatus", nativePresetPath(presetPath), makeFactory);
|
|
8742
9253
|
return json(result);
|
|
8743
9254
|
});
|
|
8744
9255
|
server.registerTool("get_sample_usage", {
|
|
@@ -8763,14 +9274,14 @@ server.registerTool("get_sample_usage", {
|
|
|
8763
9274
|
if (check !== true)
|
|
8764
9275
|
return check;
|
|
8765
9276
|
const result = mode === "presetUsesSamples"
|
|
8766
|
-
? await bridge.send("getPresetSampleUsage", relPath)
|
|
9277
|
+
? await bridge.send("getPresetSampleUsage", nativePresetPath(relPath))
|
|
8767
9278
|
: await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
|
|
8768
9279
|
return json(result);
|
|
8769
9280
|
});
|
|
8770
9281
|
server.registerTool("repair_preset_samples", {
|
|
8771
9282
|
title: "Repair Preset Sample Paths",
|
|
8772
9283
|
description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
|
|
8773
|
-
"library (optionally scoped to one preset). mode 'rescan' re-audits
|
|
9284
|
+
"library (optionally scoped to one preset). mode 'rescan' re-audits the ACTIVE preset for still-missing samples and " +
|
|
8774
9285
|
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
8775
9286
|
inputSchema: {
|
|
8776
9287
|
mode: z
|
|
@@ -8779,18 +9290,20 @@ server.registerTool("repair_preset_samples", {
|
|
|
8779
9290
|
presetPath: z
|
|
8780
9291
|
.string()
|
|
8781
9292
|
.optional()
|
|
8782
|
-
.describe("
|
|
9293
|
+
.describe("'fix' only: relative preset path. Omit to scan all presets. 'rescan' always audits the active preset."),
|
|
8783
9294
|
},
|
|
8784
9295
|
}, async ({ mode, presetPath }) => {
|
|
8785
9296
|
const check = await requireBridge();
|
|
8786
9297
|
if (check !== true)
|
|
8787
9298
|
return check;
|
|
8788
9299
|
if (mode === "fix") {
|
|
8789
|
-
const result = await bridge.send("fixPresetSamplePaths", presetPath
|
|
9300
|
+
const result = await bridge.send("fixPresetSamplePaths", presetPath ? nativePresetPath(presetPath) : "");
|
|
8790
9301
|
return json(result);
|
|
8791
9302
|
}
|
|
8792
|
-
await bridge.send("rescanMissingSamples",
|
|
8793
|
-
|
|
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." });
|
|
8794
9307
|
});
|
|
8795
9308
|
server.registerTool("preview_sample", {
|
|
8796
9309
|
title: "Preview Sample",
|
|
@@ -9147,16 +9660,23 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
9147
9660
|
result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
|
|
9148
9661
|
isError: result.isError,
|
|
9149
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.
|
|
9150
9665
|
let payload;
|
|
9151
9666
|
if (result.isError)
|
|
9152
9667
|
payload = { error: result.text.slice(0, 400) };
|
|
9153
9668
|
else {
|
|
9154
9669
|
try {
|
|
9155
9670
|
const parsed = JSON.parse(result.text);
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
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
|
+
}
|
|
9160
9680
|
}
|
|
9161
9681
|
catch {
|
|
9162
9682
|
payload = { result: result.text.slice(0, 400) };
|