@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.
Files changed (41) hide show
  1. package/README.md +2 -1
  2. package/dist/audioAnalysis.d.ts +1 -1
  3. package/dist/audioAnalysis.js +3 -4
  4. package/dist/audioAnalysis.js.map +1 -1
  5. package/dist/bridge.js +7 -8
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/drumSynth.js +1 -1
  8. package/dist/drumSynth.js.map +1 -1
  9. package/dist/drumSynthEngines.json +1 -1
  10. package/dist/fgemma-runner.js +20 -4
  11. package/dist/fgemma-runner.js.map +1 -1
  12. package/dist/generated/kvWriteOptions.d.ts +20 -0
  13. package/dist/generated/kvWriteOptions.js +45 -0
  14. package/dist/generated/kvWriteOptions.js.map +1 -0
  15. package/dist/generated/variationSnapshot.js +0 -19
  16. package/dist/generated/variationSnapshot.js.map +1 -1
  17. package/dist/generativePlayers.json +1 -1
  18. package/dist/manifest-mfx.json +35 -45
  19. package/dist/manifest-msq.json +9564 -0
  20. package/dist/manifest.js +5 -0
  21. package/dist/manifest.js.map +1 -1
  22. package/dist/manifest.json +9139 -2428
  23. package/dist/mfxCombos.js +1 -1
  24. package/dist/mfxCombos.js.map +1 -1
  25. package/dist/midiFile.d.ts +1 -1
  26. package/dist/midiFile.js +1 -1
  27. package/dist/midiFile.js.map +1 -1
  28. package/dist/mixAssist.d.ts +35 -0
  29. package/dist/mixAssist.js +247 -19
  30. package/dist/mixAssist.js.map +1 -1
  31. package/dist/patternLanes.d.ts +40 -0
  32. package/dist/patternLanes.js +108 -0
  33. package/dist/patternLanes.js.map +1 -0
  34. package/dist/presetSave.d.ts +3 -0
  35. package/dist/presetSave.js +13 -0
  36. package/dist/presetSave.js.map +1 -1
  37. package/dist/server.js +835 -315
  38. package/dist/server.js.map +1 -1
  39. package/dist/targetDenylist.json +20 -0
  40. package/dist/variationSessionKeys.json +1 -1
  41. 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 output requires a running transport. UI, video, and visual tools require the plugin " +
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
- // and neither song_mode nor a looper tool is registered on this launcher.
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
- export_audio: { mode: ["song-mode"] },
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
- export_audio: { bars: "Bars to render." },
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.replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
940
- "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.");
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
- internalTools.set(name, { shape: productConfig.inputSchema, handler });
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, handler);
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 as pam_motion_<epoch>.wav. Digital silence is " +
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("Tempo for the per-bar windows. Read from the transport when omitted."),
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
- const transport = (await bridge.send("getTransport"));
1586
- effectiveBpm ??= typeof transport?.bpm === "number" ? transport.bpm : 120;
1587
- const filename = `pam_motion_${Date.now()}`;
1588
- await bridge.send("exportAudio", {
1589
- filename,
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
- const locations = (await bridge.send("getDefaultLocations"));
1598
- const exportDir = [locations?.exports, locations?.exportsLocation, locations?.Exports].find((v) => typeof v === "string" && v.length > 0);
1599
- if (!exportDir) {
1600
- return error(`Rendered ${filename}.wav but could not resolve PAM's Exports folder from get_default_locations. ` +
1601
- "Re-run with an explicit wavPath.");
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.playing,
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(PATTERN_LANES).map(([lane, suffix]) => [
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.playing,
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(47)
2189
- .describe("Output length in seconds (Stable Audio 3 Small SFX engine cap ≈ 47 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
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 in parallel (Stable Audio 3 Small SFX) and load them into a list of cells. Useful for building a coherent drum / instrument kit from a single high-level prompt. Requires the on-device model to be downloaded (Settings → Local models).",
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). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
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 = index.get(paramId);
2510
- if (!param)
2511
- return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
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 note, pitch, slice, or stretch sequence to a cell's pattern sequencer.",
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 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
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
- // Map pattern type to state key suffix
2557
- const keyMap = {
2558
- note: "midiSequence",
2559
- midi: "midiSequence",
2560
- pitch: "pitchSequence",
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 = (await bridge.send("getDefaultLocations"));
2727
- const roots = Object.entries(locations ?? {})
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. Pass refresh:true to run a new analysis " +
2868
- "(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
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, KV_OPTS_STANDARD);
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 executable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
3543
+ const processExecutable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
3363
3544
  encoding: "utf8",
3364
3545
  }).trim();
3365
- if (executable) {
3366
- build = {
3367
- executable,
3368
- looksLikeDevBuild: executable.includes("/build/") && !executable.startsWith("/Applications/"),
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
- build.executableMtime = statSync(executable).mtime.toISOString();
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.playing,
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
- return text(`Exported preset to: ${outputFolderName}`);
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
- return null; // acked but failed / no id — don't misattribute via the diff
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 null;
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,fit}; " +
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
- const patch = { ...(fields ?? {}) };
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
- if (enabled !== undefined && instanceId)
4249
- results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId, enabled }));
4250
- if (strength !== undefined && instanceId)
4251
- results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId, strength }));
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, KV_OPTS_STANDARD);
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, KV_OPTS_STANDARD);
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, KV_OPTS_MOD);
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 all sequencer lanes (note, pitch, slice, stretch) for a single PAM/Cell1 sampler cell.",
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 suffix of PATTERN_SUFFIXES) {
4686
- delta[`cell${cellId}_${suffix}`] = [];
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, KV_OPTS_PATTERN);
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, KV_OPTS_EFFECT_CONFIG);
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/dsp/products/mfx/mfxRouting.ts): the
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 }, KV_OPTS_STANDARD);
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
- await applyKv({ mfx_edge_gains: gains }, KV_OPTS_EFFECT_CONFIG);
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, KV_OPTS_STANDARD);
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 } }, KV_OPTS_EFFECT_CONFIG);
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 }, KV_OPTS_STANDARD);
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; sampler/MIDI/instrument config is PAM/Cell1-only.",
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
- await applyKv(delta, KV_OPTS_CELL_CONFIG);
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 (KV_OPTS_STANDARD); the projection needs
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, KV_OPTS_STANDARD);
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, KV_OPTS_MASTER_CONFIG);
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/dsp/modules/drumSynth.ts derives its
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 (KV_OPTS_STANDARD).
5488
- await applyKv(delta, KV_OPTS_STANDARD);
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 }, KV_OPTS_CELL_CONFIG);
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, KV_OPTS_CELL_CONFIG);
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
- }, KV_OPTS_PATTERN);
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 = index.get(paramId);
5949
- if (!param)
5950
- return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
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 }, KV_OPTS_MOD);
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 }, KV_OPTS_MOD);
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.array(z.number().min(0).max(1)).min(2).max(64).describe("Master step values, 0..1 each."),
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' drawn note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): " +
6035
- "one track per cell on MIDI channel = cell number, each pattern tiled across `bars`. Pure file write — no " +
6036
- "render, nothing changes in PAM. Generated Player cycles are not drawn patterns; FREEZE them first (in the UI) " +
6037
- "to export what a player is playing. Default location is PAM's Exports folder.",
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: the Exports location from get_default_locations)."),
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, locations] = await Promise.all([
6389
+ const [stateRaw, transportRaw, exportsDir] = await Promise.all([
6053
6390
  bridge.send("getState"),
6054
6391
  bridge.send("getTransport").catch(() => undefined),
6055
- bridge.send("getDefaultLocations").catch(() => undefined),
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
- let dir = outputDir ? expandHome(outputDir) : undefined;
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
- const tracks = cells.map((cell) => ({
6093
- name: `Cell ${cell.id}`,
6094
- events: tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, Math.min(16, cell.id)),
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 }, KV_OPTS_MOD);
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: KV_OPTS_LOOPER_STATE,
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 current looper state into the slot).",
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.125)
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 (object). Omit this field to clear the slot instead."),
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
- await bridge.send("saveLooperVariation", { index: args.index, data: args.data ?? null });
7208
- return text(args.data
7209
- ? `Saved looper variation slot ${args.index}.`
7210
- : `Cleared looper variation slot ${args.index} (no data).`);
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.number().min(0.125).max(64).optional().describe("Sync interval in bars."),
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.125).max(64).describe("Bars before switching."),
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
- await bridge.send("syncVariationHoldBindings", bindings);
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().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
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(0).describe("Slice index (0-based)."),
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 (args) => {
8551
+ }, async ({ cellId, sourceBpm, targetBpm }) => {
8101
8552
  const check = await requireBridge();
8102
8553
  if (check !== true)
8103
8554
  return check;
8104
- await bridge.send("resampleSampleToBPM", args);
8105
- return text(`Resampled cell ${args.cellId} to BPM.`);
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(0).describe("Slice index (0-based)."),
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
- .min(0.125)
8627
+ .int()
8628
+ .min(1)
8171
8629
  .max(64)
8172
8630
  .optional()
8173
- .describe("For 'start': capture length in bars (max 64; the capture also stops on the 'stop' op)."),
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 full param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param). " +
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
- return text(bridgeResultText(raw));
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 null when running plugin-hosted (the DAW owns the device config). Use the device IDs/names from here with set_midi_io.",
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", [deviceName]);
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). Use get_audio_device_setup to discover device IDs first. " +
8664
- "All fields optional.",
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.array(z.string()).optional().describe("Enabled MIDI input device IDs (replaces the active set)."),
8667
- outputDeviceId: z.string().optional().describe("MIDI output device ID."),
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", args.inputDeviceIds);
8677
- applied.push(`inputs=${args.inputDeviceIds.length}`);
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", args.outputDeviceId);
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
- const result = await bridge.send("removePreset", presetPath);
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 a preset for still-missing samples and " +
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("Relative preset path. Omit for 'fix' to scan all presets; omit for 'rescan' to audit the active preset."),
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", presetPath ?? "");
8793
- return text(`Re-audited ${presetPath || "active preset"} for missing samples (results dispatched to UI state).`);
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
- payload =
9157
- parsed && typeof parsed === "object" && !Array.isArray(parsed) && result.text.length <= 400
9158
- ? parsed
9159
- : { result: result.text.slice(0, 400) };
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) };