@map-audio/pam-mcp-server 1.22.0 → 1.27.0

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