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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -8,8 +8,9 @@ import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
8
8
  import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
9
9
  import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
10
10
  import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
11
- import { readFile } from "node:fs/promises";
12
- import { existsSync, readFileSync } from "node:fs";
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { execFileSync } from "node:child_process";
13
14
  import { inflateSync } from "node:zlib";
14
15
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
@@ -17,10 +18,13 @@ import { createDrumSynthCatalog, } from "./drumSynth.js";
17
18
  import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
18
19
  import { PluginBridge } from "./bridge.js";
19
20
  import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
20
- import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
21
+ import { normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
21
22
  import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
22
23
  import { mcpProductProfile } from "./productProfile.js";
23
24
  import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
25
+ import { GENERATIVE_PLAYERS, LISTEN_MODES, MODULATABLE_PLAYER_FIELDS, NO_POLY_ARM, PATTERN_MODES, PLAYER_GENRES, PLAYER_NAMES, PLAYER_SLOT_FIELDS, cellPlayerType, describeCellPlayer, describePlayerSpec, getPlayerSpec, isMelodicPlayer, playerDefaultsFor, playerDensityDisplay, playerGenreConfig, playerMutateDisplay, playerPresetsFor, playerSlotDisplay, presetConfig, randomPlayerSeed, resolvePlayerPreset, resolvePlayerSpec, resolveSlotValue, rolePlayerConfigFor, roleSpreadSummary, snapPlayerValue, } from "./players.js";
26
+ import { buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
27
+ import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries } from "./sampleSlicing.js";
24
28
  // --- State ---
25
29
  // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
26
30
  // interface/src/stores/Preset.ts. Defined locally because the MCP package
@@ -196,22 +200,6 @@ async function requireBridgeForCells(...cellIds) {
196
200
  }
197
201
  return true;
198
202
  }
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
203
  // PAM ≥ 1.4.61 stores variations ONLY in the data block, zlib+base64-compressed with a
216
204
  // "PAMZ1:" marker (see helpers/VariationCodec.h). Decompress one entry to plain JSON;
217
205
  // plain (older-format) entries pass through unchanged.
@@ -241,20 +229,17 @@ function variationsFromParsedPreset(parsed) {
241
229
  return Array(PRESET_VARIATION_SLOTS).fill(null);
242
230
  return src.map(decompressVariationEntry);
243
231
  }
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) {
232
+ async function readPresetForSave(presetPath, allowMissing = false) {
233
+ let fullPath;
249
234
  try {
250
- const fullPath = await resolvePresetPath(presetPath);
251
- const raw = await readFile(fullPath, "utf-8");
252
- return variationsFromParsedPreset(parsePresetFile(raw));
235
+ fullPath = await resolvePresetPath(presetPath);
253
236
  }
254
- catch {
255
- // Preset doesn't exist yet or can't be read — start fresh
256
- return Array(PRESET_VARIATION_SLOTS).fill(null);
237
+ catch (error) {
238
+ if (allowMissing && error.code === "ENOENT")
239
+ return null;
240
+ throw error;
257
241
  }
242
+ return parsePresetFile(await readFile(fullPath));
258
243
  }
259
244
  const VARIATION_CONTRACT = (() => {
260
245
  const here = dirname(fileURLToPath(import.meta.url));
@@ -294,6 +279,7 @@ const VARIATION_EXCLUDED_PARAMS = [
294
279
  function buildVariationString(state) {
295
280
  return JSON.stringify(buildVariationSnapshotBase(state, {
296
281
  sessionKeys: VARIATION_EXCLUDED_PARAMS,
282
+ transientKeys: VARIATION_CONTRACT.transientDispatchKeys ?? [],
297
283
  visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
298
284
  lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
299
285
  }));
@@ -583,13 +569,9 @@ const server = new McpServer({
583
569
  version: SERVER_VERSION,
584
570
  }, {
585
571
  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. " +
572
+ ? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
573
+ "Use cells 1-4. Treat tool annotations as authoritative and use select_instance when several products are running."
574
+ : "PAM is an eight-cell sampler. " +
593
575
  "Sequencer output requires a running transport. UI, video, and visual tools require the plugin " +
594
576
  "window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
595
577
  "feature flag. Treat tool annotations as authoritative and use select_instance when several " +
@@ -624,13 +606,14 @@ const TOOL_ANNOTATIONS = {
624
606
  get_design_overview: RO,
625
607
  get_transport: RO,
626
608
  get_master_analysis: RO,
627
- suggest_mix: RO, // pure function over cached analysis + current state
609
+ suggest_mix: W,
628
610
  connection_status: RO,
611
+ situation: RO,
629
612
  list_samples: RO,
630
613
  get_default_locations: RO,
631
614
  get_audio_info: RO,
632
615
  get_video_state: RO,
633
- capture_ui: RO, // writes a screenshot file, mutates no plugin state
616
+ capture_ui: WI,
634
617
  get_sample_peaks: RO,
635
618
  get_audio_device_setup: RO,
636
619
  get_memory_report: RO,
@@ -663,6 +646,11 @@ const TOOL_ANNOTATIONS = {
663
646
  remove_param_seq: DI,
664
647
  set_drum_synth: WI,
665
648
  set_drum_synth_wave: WI,
649
+ list_players: RO,
650
+ set_player: WI,
651
+ set_slices: WI,
652
+ set_free_sequencer: WI,
653
+ export_midi: W,
666
654
  stop_cell: WI,
667
655
  reset_cell: DI,
668
656
  copy_cell: DI, // overwrites the destination cell
@@ -682,6 +670,7 @@ const TOOL_ANNOTATIONS = {
682
670
  midi_recording: W,
683
671
  // Presets / variations
684
672
  load_preset: DI, // replaces the working state (unsaved edits are lost)
673
+ get_preset_load_status: RO,
685
674
  save_preset: WI,
686
675
  new_preset: DI,
687
676
  import_preset: WI,
@@ -772,7 +761,7 @@ const TOOL_ANNOTATIONS = {
772
761
  set_video_fx: WI,
773
762
  remove_video_fx: DI,
774
763
  bind_video_param: WI,
775
- set_video_settings: WI,
764
+ set_video_settings: D, // resetWorkspace:true clears the scene
776
765
  vx_variation: W,
777
766
  // Connection management
778
767
  select_instance: WI,
@@ -797,6 +786,7 @@ const MFX_TOOL_NAMES = new Set([
797
786
  "get_design_overview",
798
787
  "get_transport",
799
788
  "connection_status",
789
+ "situation",
800
790
  "get_default_locations",
801
791
  "capture_ui",
802
792
  "get_memory_report",
@@ -811,6 +801,7 @@ const MFX_TOOL_NAMES = new Set([
811
801
  "add_param_seq",
812
802
  "update_param_seq",
813
803
  "remove_param_seq",
804
+ "set_free_sequencer",
814
805
  "stop_cell",
815
806
  "reset_cell",
816
807
  "copy_cell",
@@ -821,6 +812,7 @@ const MFX_TOOL_NAMES = new Set([
821
812
  "set_metronome",
822
813
  "configure_transport",
823
814
  "load_preset",
815
+ "get_preset_load_status",
824
816
  "save_preset",
825
817
  "new_preset",
826
818
  "import_preset",
@@ -836,6 +828,7 @@ const MFX_TOOL_NAMES = new Set([
836
828
  "variation_hold",
837
829
  "export_audio",
838
830
  "update_settings",
831
+ "get_audio_device_setup",
839
832
  "set_audio_device",
840
833
  "set_mfx_routing",
841
834
  "set_mfx_edge_gain",
@@ -847,27 +840,138 @@ const MFX_TOOL_NAMES = new Set([
847
840
  ]);
848
841
  function mfxSurfaceText(value) {
849
842
  return value
843
+ // MFX has its own version line, so a PAM version gate names a build MFX never reaches.
844
+ .replace(/PAM ?(?:≥|>=) ?\d+(?:\.\d+)*/g, "a current MFX build")
845
+ .replaceAll("a PAM build", "an MFX build")
846
+ .replaceAll(" saved via set_visual", "")
847
+ .replaceAll(" Check corr in get_master_analysis after widening.", "")
848
+ .replaceAll(" (same constraint as the video tools)", "")
849
+ .replaceAll("so the preset_novelty scale does", "so a whole-preset novelty scale does")
850
850
  .replaceAll("PAM/Cell1-only", "sampler-product-only")
851
851
  .replaceAll("PAM/Cell1", "sampler products")
852
852
  .replaceAll("PAM-only", "sampler-product-only")
853
853
  .replaceAll("PAM's", "MFX's")
854
854
  .replaceAll("PAM", "MFX");
855
855
  }
856
+ const MFX_MAX_CELL = 4;
857
+ // Zod wrappers are immutable, so narrowing a leaf means rebuilding the
858
+ // optional/default/array/union chain around it and re-attaching the description.
859
+ function rebuildSchema(schema, narrowLeaf) {
860
+ const def = schema._zod?.def;
861
+ const keepText = (next) => (schema.description ? next.describe(schema.description) : next);
862
+ const inner = () => rebuildSchema(def.innerType, narrowLeaf);
863
+ switch (def?.type) {
864
+ case "optional":
865
+ return keepText(inner().optional());
866
+ case "nullable":
867
+ return keepText(inner().nullable());
868
+ case "default":
869
+ return keepText(inner().default(def.defaultValue));
870
+ case "array":
871
+ return keepText(z.array(rebuildSchema(def.element, narrowLeaf)));
872
+ case "union":
873
+ return keepText(z.union(def.options.map((option) => rebuildSchema(option, narrowLeaf))));
874
+ default:
875
+ return keepText(narrowLeaf(schema));
876
+ }
877
+ }
878
+ function schemaKind(schema) {
879
+ return schema._zod?.def?.type;
880
+ }
881
+ const capCellId = (leaf) => schemaKind(leaf) === "number" ? leaf.max(MFX_MAX_CELL) : leaf;
882
+ const dropEnumMembers = (drop) => (leaf) => {
883
+ if (schemaKind(leaf) !== "enum")
884
+ return leaf;
885
+ const entries = leaf._zod.def.entries;
886
+ const kept = Object.values(entries).filter((value) => !drop.includes(value));
887
+ return z.enum(kept);
888
+ };
889
+ // MFX ships four cells and the shared schemas advertise PAM's eight. The runtime
890
+ // guard is manifest-driven; this is the advertised half of the same contract.
891
+ const MFX_CELL_FIELDS = new Set([
892
+ "cellId",
893
+ "sourceCellId",
894
+ "targetCellId",
895
+ "cellIds",
896
+ "selectedCellIds",
897
+ "sidechainFrom",
898
+ ]);
899
+ // Enum members MFX has no surface for: Vary is removed from its modulation stack,
900
+ // and neither song_mode nor a looper tool is registered on this launcher.
901
+ const MFX_ENUM_DROPS = {
902
+ add_modulation: { type: ["vary"] },
903
+ get_state: { section: ["vary", "loopers", "songMode"] },
904
+ export_audio: { mode: ["song-mode"] },
905
+ };
906
+ const MFX_KEPT_FIELDS = {
907
+ update_cell_config: ["cellId", "sidechainFrom"],
908
+ };
909
+ const MFX_DROPPED_FIELDS = {
910
+ select_instance: ["product"],
911
+ // MFX cells are effect slots — there is no synth/sampler tab to switch.
912
+ capture_ui: ["instrumentView"],
913
+ };
914
+ const MFX_TOOL_DESCRIPTIONS = {
915
+ select_instance: "Choose which running MFX instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses non-MFX instances.",
916
+ update_cell_config: "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor.",
917
+ capture_ui: "Render MFX's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to " +
918
+ "view the image). Optionally compose the view first: gridSize (1 or 4), mainView (CellGrid|MfxRouting|" +
919
+ "CtrlAll|Envelope|LFO|ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MFX's window OPEN — " +
920
+ "html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
921
+ "cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
922
+ "not restored.",
923
+ };
924
+ const MFX_FIELD_DESCRIPTIONS = {
925
+ capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
926
+ export_audio: { bars: "Bars to render." },
927
+ add_modulation: {
928
+ cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
929
+ },
930
+ };
931
+ // Text whose subject is a tool this launcher does not register.
932
+ function mfxDescriptionEdits(name, value) {
933
+ if (name === "export_audio")
934
+ return value
935
+ .replace("Set `mode` to bounce stems or the whole Song Mode arrangement.", "Set `mode` to bounce per-cell or per-bus stems.")
936
+ .replace(" · 'song-mode' (the Song Mode timeline)", "")
937
+ .replace(/\n\nNOTE on 'song-mode':[\s\S]*$/, "");
938
+ if (name === "analyze_motion")
939
+ return value.replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
940
+ "density), neither of which can tell a modulated preset from a static one.", "No other tool on this surface can tell a modulated preset from a static one.");
941
+ if (name === "set_master_effects")
942
+ return value.replace("Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). ", "Aim for tp <= -1 dBTP and PSR >= 8 in the loudest section; no loudness analysis tool is registered on this surface. ");
943
+ if (name === "set_parameters")
944
+ return (value +
945
+ " Writing mfx_combo1, mfx_combo2 or mfx_combo3 also fans the value out to that combo's resolved target " +
946
+ "parameters, unless one of those targets is written in the same call.");
947
+ return value;
948
+ }
856
949
  function mfxToolConfig(name, config) {
857
950
  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"));
951
+ const kept = MFX_KEPT_FIELDS[name];
952
+ const droppedFields = MFX_DROPPED_FIELDS[name] ?? [];
953
+ const enumDrops = MFX_ENUM_DROPS[name] ?? {};
954
+ const fieldText = MFX_FIELD_DESCRIPTIONS[name] ?? {};
860
955
  const inputSchema = source.inputSchema
861
- ? Object.fromEntries(inputEntries.map(([key, schema]) => [
862
- key,
863
- schema.description ? schema.describe(mfxSurfaceText(schema.description)) : schema,
864
- ]))
956
+ ? Object.fromEntries(Object.entries(source.inputSchema)
957
+ .filter(([key]) => (!kept || kept.includes(key)) && !droppedFields.includes(key))
958
+ .map(([key, schema]) => {
959
+ let narrowed = schema;
960
+ if (enumDrops[key])
961
+ narrowed = rebuildSchema(narrowed, dropEnumMembers(enumDrops[key]));
962
+ if (MFX_CELL_FIELDS.has(key))
963
+ narrowed = rebuildSchema(narrowed, capCellId);
964
+ const described = fieldText[key] ??
965
+ (narrowed.description
966
+ ? mfxSurfaceText(MFX_CELL_FIELDS.has(key)
967
+ ? narrowed.description.replaceAll("1-8", "1-4").replaceAll("1..8", "1..4")
968
+ : narrowed.description)
969
+ : undefined);
970
+ return [key, described ? narrowed.describe(described) : narrowed];
971
+ }))
865
972
  : 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;
973
+ const description = MFX_TOOL_DESCRIPTIONS[name] ??
974
+ (source.description ? mfxSurfaceText(mfxDescriptionEdits(name, source.description)) : undefined);
871
975
  return {
872
976
  ...source,
873
977
  ...(description ? { description } : {}),
@@ -996,7 +1100,7 @@ server.registerTool("get_parameter_info", {
996
1100
  });
997
1101
  server.registerTool("list_presets", {
998
1102
  title: "List Presets",
999
- description: "List available PAM presets (factory and user).",
1103
+ 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
1104
  inputSchema: {
1001
1105
  search: z.string().optional().describe("Filter presets by name."),
1002
1106
  },
@@ -1127,9 +1231,8 @@ server.registerTool("design_context", {
1127
1231
  groups: z
1128
1232
  .array(z.string())
1129
1233
  .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."),
1234
+ .describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder']). " +
1235
+ "Omit it to get the full per-cell vocabulary."),
1133
1236
  },
1134
1237
  annotations: { readOnlyHint: true },
1135
1238
  }, async ({ cellId, groups: groupFilter }) => {
@@ -1278,7 +1381,7 @@ server.registerTool("design_context", {
1278
1381
  compressor: group("compressorThreshold", "compressorRatio", "compressorAttack", "compressorRelease"),
1279
1382
  panner: group("pannerBalance", "pannerWidth"),
1280
1383
  clip: group("gainClip", "gainClipMode"),
1281
- drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synth_p1", "synth_p2", "synth_p3"),
1384
+ drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synthWaveFm", "synth_p1", "synth_p2", "synth_p3"),
1282
1385
  // Everything below is a real per-cell engine that no shipped factory
1283
1386
  // preset has ever touched. They are the least-explored territory in the
1284
1387
  // instrument, which makes them the first place to look when the brief
@@ -1360,6 +1463,9 @@ server.registerTool("design_context", {
1360
1463
  return [`cell${id}`, 47 + id];
1361
1464
  })),
1362
1465
  variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
1466
+ generativePlayers: "set_player turns a cell into a live generator (list_players for the roster: EUCLID, KIT, TURING, WALK, MARKOV, " +
1467
+ "RATCHET, PHASE, RIFF, MASK). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
1468
+ "write them through set_player, never set_parameters alone, so the cell config stays in step.",
1363
1469
  modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
1364
1470
  "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
1471
  });
@@ -1454,8 +1560,9 @@ server.registerTool("analyze_motion", {
1454
1560
  "a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
1455
1561
  "identical to each other.\n\n" +
1456
1562
  "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.",
1563
+ "export first and leaves that render in PAM's Exports folder as pam_motion_<epoch>.wav. Digital silence is " +
1564
+ "reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
1565
+ "silence in the standalone build.",
1459
1566
  inputSchema: {
1460
1567
  wavPath: z
1461
1568
  .string()
@@ -1840,12 +1947,14 @@ server.registerTool("get_design_overview", {
1840
1947
  ]));
1841
1948
  return {
1842
1949
  cellId: id,
1950
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
1843
1951
  sample: {
1844
1952
  name: stemName(samplePath),
1845
1953
  path: typeof samplePath === "string" ? samplePath : undefined,
1846
1954
  },
1847
1955
  // Both halves: the enum an agent writes back, and the name it reasons about.
1848
1956
  synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
1957
+ player: describeCellPlayer(id, cellState, state),
1849
1958
  enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1850
1959
  patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
1851
1960
  lane,
@@ -1870,6 +1979,7 @@ server.registerTool("get_design_overview", {
1870
1979
  },
1871
1980
  totals: {
1872
1981
  cellsWithSamples: cells.filter((c) => c.sample.path).length,
1982
+ playersRunning: cells.filter((c) => c.player).length,
1873
1983
  activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
1874
1984
  modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
1875
1985
  },
@@ -1880,7 +1990,8 @@ server.registerTool("set_parameters", {
1880
1990
  title: "Set Parameters",
1881
1991
  description: "Set one or more PAM parameters. Values are validated against the manifest. " +
1882
1992
  "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.",
1993
+ "when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges. " +
1994
+ "Writing a macro_* id also enables that macro, matching the UI.",
1884
1995
  inputSchema: {
1885
1996
  parameters: z
1886
1997
  .record(z.string(), z.number())
@@ -1925,7 +2036,7 @@ server.registerTool("set_parameters", {
1925
2036
  delta.macros = macros;
1926
2037
  }
1927
2038
  let comboTargetCount = 0;
1928
- if (IS_MFX_SERVER) {
2039
+ if (isMfxManifest(await loadManifest())) {
1929
2040
  const comboValues = new Map();
1930
2041
  for (let comboIndex = 1; comboIndex <= MFX_COMBO_COUNT; comboIndex++) {
1931
2042
  const value = parameters[`mfx_combo${comboIndex}`];
@@ -1970,7 +2081,7 @@ server.registerTool("set_parameters", {
1970
2081
  });
1971
2082
  server.registerTool("load_sample", {
1972
2083
  title: "Load Sample",
1973
- description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell.",
2084
+ 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
2085
  inputSchema: {
1975
2086
  filePath: z.string().describe("Path to the audio file (absolute, or relative as returned by list_samples)."),
1976
2087
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -2149,6 +2260,7 @@ server.registerTool("generate_kit", {
2149
2260
  // curve / prompt / seed / sigma), sidecar renders, and the take shelf. Requires
2150
2261
  // experimentalMode + experimentalAIGen (update_settings) AND the SA3 Python
2151
2262
  // sidecar running on 127.0.0.1:8093 (packages/sa3-workbench-sidecar/README.md).
2263
+ const WORKBENCH_PREREQ = "Requires experimentalMode + experimentalAIGen (update_settings) and the SA3 sidecar on 127.0.0.1:8093; check workbench_sidecar_status first. ";
2152
2264
  const WORKBENCH_MASK_REGION = z.object({
2153
2265
  startSec: z.number().min(0).describe("Region start in seconds (source time)."),
2154
2266
  endSec: z.number().min(0).describe("Region end in seconds (must be > startSec)."),
@@ -2184,7 +2296,8 @@ const WORKBENCH_STATE_FIELDS = {
2184
2296
  };
2185
2297
  server.registerTool("workbench_get_state", {
2186
2298
  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: [...] }.",
2299
+ description: WORKBENCH_PREREQ +
2300
+ "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
2301
  inputSchema: {},
2189
2302
  }, async () => {
2190
2303
  const check = await requireBridge();
@@ -2194,7 +2307,8 @@ server.registerTool("workbench_get_state", {
2194
2307
  });
2195
2308
  server.registerTool("workbench_set_state", {
2196
2309
  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.",
2310
+ description: WORKBENCH_PREREQ +
2311
+ "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
2312
  inputSchema: WORKBENCH_STATE_FIELDS,
2199
2313
  }, async (args) => {
2200
2314
  const check = await requireBridge();
@@ -2204,7 +2318,8 @@ server.registerTool("workbench_set_state", {
2204
2318
  });
2205
2319
  server.registerTool("workbench_render", {
2206
2320
  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.",
2321
+ description: WORKBENCH_PREREQ +
2322
+ "Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
2208
2323
  inputSchema: {
2209
2324
  ...WORKBENCH_STATE_FIELDS,
2210
2325
  sourcePath: z.string().optional()
@@ -2223,7 +2338,7 @@ server.registerTool("workbench_render", {
2223
2338
  });
2224
2339
  server.registerTool("workbench_list_takes", {
2225
2340
  title: "Workbench: List Takes",
2226
- description: "List all Workbench takes (renders) with full provenance, oldest first.",
2341
+ description: WORKBENCH_PREREQ + "List all Workbench takes (renders) with full provenance, oldest first.",
2227
2342
  inputSchema: {},
2228
2343
  }, async () => {
2229
2344
  const check = await requireBridge();
@@ -2233,7 +2348,7 @@ server.registerTool("workbench_list_takes", {
2233
2348
  });
2234
2349
  server.registerTool("workbench_load_take", {
2235
2350
  title: "Workbench: Load Take",
2236
- description: "Load a rendered take's WAV into a cell (same native path as load_sample).",
2351
+ description: WORKBENCH_PREREQ + "Load a rendered take's WAV into a cell (same native path as load_sample).",
2237
2352
  inputSchema: {
2238
2353
  takeId: z.string().describe("Take id from workbench_render / workbench_list_takes."),
2239
2354
  cellId: z.number().int().min(1).max(8).describe("Target cell."),
@@ -2251,7 +2366,8 @@ server.registerTool("workbench_load_take", {
2251
2366
  });
2252
2367
  server.registerTool("workbench_undo", {
2253
2368
  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 }.",
2369
+ description: WORKBENCH_PREREQ +
2370
+ "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
2371
  inputSchema: {},
2256
2372
  }, async () => {
2257
2373
  const check = await requireBridge();
@@ -2261,7 +2377,8 @@ server.registerTool("workbench_undo", {
2261
2377
  });
2262
2378
  server.registerTool("workbench_reset_chain", {
2263
2379
  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 }.",
2380
+ description: WORKBENCH_PREREQ +
2381
+ "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
2382
  inputSchema: {},
2266
2383
  }, async () => {
2267
2384
  const check = await requireBridge();
@@ -2322,17 +2439,31 @@ server.registerTool("stop_cell", {
2322
2439
  });
2323
2440
  server.registerTool("load_preset", {
2324
2441
  title: "Load Preset",
2325
- description: "Load a preset into the running PAM plugin.",
2442
+ description: "Queue a preset load through PAM's native load/morph lifecycle. Returns requestId and status:'queued'. " +
2443
+ "Poll get_preset_load_status until status:'succeeded' before editing, randomizing, or saving; failure, cancellation, and supersession are explicit. " +
2444
+ "Replaces working state and the variation bank; save_preset first if unsaved edits matter. Requires a plugin build with tracked preset loading.",
2326
2445
  inputSchema: {
2327
2446
  path: z.string().describe("Preset file path (absolute or relative to preset dirs)."),
2447
+ immediate: z.boolean().optional().describe("true bypasses the preset morph; false/omitted follows the native preset morph settings."),
2328
2448
  },
2329
- }, async ({ path: presetPath }) => {
2449
+ }, async ({ path: presetPath, immediate }) => {
2330
2450
  const check = await requireBridge();
2331
2451
  if (check !== true)
2332
2452
  return check;
2333
2453
  const fullPath = await resolvePresetPath(presetPath);
2334
- await bridge.send("loadPreset", fullPath);
2335
- return text(`Loaded preset: ${basename(fullPath, ".preset")}`);
2454
+ return json(await bridge.send("loadPresetTracked", fullPath, immediate === true));
2455
+ });
2456
+ server.registerTool("get_preset_load_status", {
2457
+ title: "Get Preset Load Status",
2458
+ description: "Read the outcome of a load_preset request without requiring the plugin window. " +
2459
+ "Statuses: queued, morphing, succeeded, failed, cancelled, superseded. Only succeeded confirms the world committed, including same-path reloads. " +
2460
+ "PAM retains the latest 64 requests per instance; unknown/expired IDs return an error. Keep the same selected instance while polling.",
2461
+ inputSchema: { requestId: z.string().min(1).describe("requestId returned by load_preset.") },
2462
+ }, async ({ requestId }) => {
2463
+ const check = await requireBridge();
2464
+ if (check !== true)
2465
+ return check;
2466
+ return json(await bridge.send("getPresetLoadStatus", requestId));
2336
2467
  });
2337
2468
  server.registerTool("add_modulation", {
2338
2469
  title: "Add Modulation",
@@ -2476,7 +2607,10 @@ server.registerTool("randomize", {
2476
2607
  description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
2477
2608
  "target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
2478
2609
  "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.",
2610
+ "to be OPEN (samples/sequences/full run natively and do not). 'lfos'/'mods' are global and require an open window. " +
2611
+ "Returns queued, not completed: poll get_state for the requested changes before another randomize, edit, or save. " +
2612
+ "Concurrent requests can coalesce; a preset/variation load can discard pending work. " +
2613
+ "Use target:'full' to chain sample selection and pattern generation in the native engine.",
2480
2614
  inputSchema: {
2481
2615
  // 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
2482
2616
  // like 'synth'. The describe() text is pinned (exported toolpack bytes).
@@ -2487,7 +2621,7 @@ server.registerTool("randomize", {
2487
2621
  cellIds: z
2488
2622
  .array(z.number().min(1).max(8))
2489
2623
  .optional()
2490
- .describe("Specific cells to randomize. Omit for all."),
2624
+ .describe("Specific cells to randomize. Omit for all. Not supported for global 'lfos'/'mods'."),
2491
2625
  genreHint: z
2492
2626
  .string()
2493
2627
  .optional()
@@ -2518,6 +2652,8 @@ server.registerTool("randomize", {
2518
2652
  .describe("Rhythmic placement for 'sequences'/'full' (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
2519
2653
  },
2520
2654
  }, async ({ target, cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
2655
+ if ((target === "lfos" || target === "mods") && cellIds !== undefined)
2656
+ return error(`target:'${target}' randomizes global modulation; omit cellIds or edit individual links with update_modulation.`);
2521
2657
  const check = await requireBridgeForCells(cellIds);
2522
2658
  if (check !== true)
2523
2659
  return check;
@@ -2525,23 +2661,21 @@ server.registerTool("randomize", {
2525
2661
  // Routed to the canonical WebView randomizer (single source of truth) — needs the
2526
2662
  // window open. Empty array → all non-excluded cells.
2527
2663
  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.)`);
2664
+ return json({ status: "queued", target, cellIds, note: "Open PAM's window and poll get_design_overview for synth changes before editing or saving." });
2530
2665
  }
2531
2666
  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
2667
  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.)`);
2668
+ return json({ status: "queued", target, note: "Open PAM's window and poll get_state for modulation changes before editing or saving." });
2538
2669
  }
2539
2670
  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(", ")}` : ""}.`);
2671
+ 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
2672
  });
2542
2673
  server.registerTool("randomize_samples", {
2543
2674
  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.",
2675
+ description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role. Supports folder filtering. " +
2676
+ "Returns queued: poll get_state section:'cells' for sample changes before generating patterns, editing, or saving. " +
2677
+ "Serialize requests because concurrent requests can coalesce; a preset/variation load can discard pending work. " +
2678
+ "For samples plus patterns, prefer randomize target:'full'.",
2545
2679
  inputSchema: {
2546
2680
  cellIds: z
2547
2681
  .array(z.number().min(1).max(8))
@@ -2573,8 +2707,8 @@ server.registerTool("randomize_samples", {
2573
2707
  .describe("Anti-centroid mode (0 = default behaviour, 1 = maximum spread). The native engine scores FOLDER " +
2574
2708
  "COHERENCE, which is correct for a kit-from-one-pack and is also why repeated randomizes keep landing " +
2575
2709
  "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."),
2710
+ "engine to it, keeping role assignment while removing its ability to converge. Ignored when " +
2711
+ "allowedSamplePaths is given."),
2578
2712
  seed: z
2579
2713
  .number()
2580
2714
  .int()
@@ -2609,11 +2743,14 @@ server.registerTool("randomize_samples", {
2609
2743
  }
2610
2744
  }
2611
2745
  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}`);
2746
+ return json({ status: "queued", target: "samples", cellIds, genreHint,
2747
+ note: "Poll get_state section:'cells' for changed sample paths before another randomize, patterns, edit, or save." + diversifyNote });
2613
2748
  });
2614
2749
  server.registerTool("randomize_patterns", {
2615
2750
  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.",
2751
+ description: "Use PAM's native pattern generation engine to create note patterns based on loaded sample roles, genre, and BPM. " +
2752
+ "Identical to randomize target:'sequences'. Returns queued: poll get_state for changed cellN_midiSequence values " +
2753
+ "before another randomize, edit, or save. Wait for sample loading/randomization first; concurrent requests can coalesce.",
2617
2754
  inputSchema: {
2618
2755
  cellIds: z
2619
2756
  .array(z.number().min(1).max(8))
@@ -2636,8 +2773,8 @@ server.registerTool("randomize_patterns", {
2636
2773
  .optional()
2637
2774
  .describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5. " +
2638
2775
  "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."),
2776
+ "rhythm, which is exactly what makes a batch of presets feel the same; 0.7-1.0 buys rhythmic " +
2777
+ "distinctness, low values keep a stock groove."),
2641
2778
  density: z
2642
2779
  .number()
2643
2780
  .min(-1)
@@ -2656,11 +2793,12 @@ server.registerTool("randomize_patterns", {
2656
2793
  if (check !== true)
2657
2794
  return check;
2658
2795
  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})` : ""}.`);
2796
+ return json({ status: "queued", target: "sequences", cellIds, genreHint,
2797
+ note: "Poll get_state for changed cellN_midiSequence values before another randomize, edit, or save." });
2660
2798
  });
2661
2799
  server.registerTool("load_variation", {
2662
2800
  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.",
2801
+ 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
2802
  inputSchema: {
2665
2803
  index: z
2666
2804
  .number()
@@ -2674,7 +2812,7 @@ server.registerTool("load_variation", {
2674
2812
  if (check !== true)
2675
2813
  return check;
2676
2814
  await bridge.send("loadVariation", index);
2677
- return text(`Loaded variation ${index} (UI slot ${index + 1}).`);
2815
+ return text(`Requested variation ${index} (UI slot ${index + 1}); verify currentVariation and the intended state with get_state before editing or saving.`);
2678
2816
  });
2679
2817
  server.registerTool("full_stop_transport", {
2680
2818
  title: "Full Stop Transport",
@@ -2728,7 +2866,7 @@ server.registerTool("get_mix_analysis", {
2728
2866
  "true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
2729
2867
  "spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
2730
2868
  "(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.",
2869
+ "playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
2732
2870
  inputSchema: {
2733
2871
  refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
2734
2872
  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 +2921,7 @@ server.registerTool("suggest_mix", {
2783
2921
  "partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
2784
2922
  "settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
2785
2923
  "as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
2786
- "Requires PAM ≥ 1.17.0. PAM only.",
2924
+ "Requires a PAM build with Mix Assist support.",
2787
2925
  inputSchema: {
2788
2926
  scopes: z
2789
2927
  .array(z.enum(["balance", "pan", "eq", "dynamics"]))
@@ -2870,7 +3008,8 @@ server.registerTool("new_preset", {
2870
3008
  });
2871
3009
  server.registerTool("save_preset", {
2872
3010
  title: "Save Preset",
2873
- description: "Save the current PAM state as a new preset file with metadata. " +
3011
+ description: "Save the current PAM state as a preset file with metadata. Omit `name` to re-save the active user preset in place " +
3012
+ "(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
3013
  "Writes to the user preset folder by default; pass `path` for a specific location. " +
2875
3014
  "Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
2876
3015
  inputSchema: {
@@ -2921,19 +3060,12 @@ server.registerTool("save_preset", {
2921
3060
  return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
2922
3061
  if (path === undefined)
2923
3062
  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
3063
+ const parsed = await readPresetForSave(readPath);
3064
+ for (const [k, v] of Object.entries(parsed.metadata)) {
3065
+ if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
3066
+ continue;
3067
+ if (extras[k] === undefined)
3068
+ extras[k] = v;
2937
3069
  }
2938
3070
  }
2939
3071
  const metadata = { name, factory: false };
@@ -2952,7 +3084,11 @@ server.registerTool("save_preset", {
2952
3084
  // the new destination starts from eight empty slots because that file does
2953
3085
  // not exist yet, which used to collapse every named save to one slot.
2954
3086
  const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
2955
- const existingVariations = await readExistingVariations(variationSourcePath);
3087
+ const hasActivePreset = typeof state.activePresetPath === "string"
3088
+ && state.activePresetPath.length > 0 && state.activePresetPath !== "Init";
3089
+ const source = await readPresetForSave(variationSourcePath, !hasActivePreset);
3090
+ const existingVariations = source
3091
+ ? variationsFromParsedPreset(source) : Array(PRESET_VARIATION_SLOTS).fill(null);
2956
3092
  const hasVariations = existingVariations.some((v) => v !== null);
2957
3093
  if (!hasVariations) {
2958
3094
  existingVariations[0] = variationString;
@@ -3002,49 +3138,25 @@ server.registerTool("save_variation", {
3002
3138
  const check = await requireBridge();
3003
3139
  if (check !== true)
3004
3140
  return check;
3005
- // 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
3006
3141
  const { state, canonical } = await fetchSaveSnapshot();
3007
3142
  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;
3143
+ if (!name && state.activePresetPath === "Init") {
3144
+ return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
3024
3145
  }
3146
+ const activePath = typeof state.activePresetPath === "string" && state.activePresetPath !== "Init"
3147
+ ? normalizePresetLookupPath(state.activePresetPath) : "";
3148
+ const presetName = name || (activePath.split("/").pop() || "").replace(/\.preset$/, "") || lastSavedPresetName;
3025
3149
  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
3150
+ return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
3151
+ }
3152
+ const diskPath = name ? `User/${presetName}.preset`
3153
+ : activePath || lastSavedPresetPath || `User/${presetName}.preset`;
3154
+ const sourcePath = selectSaveVariationSourcePath(activePath, diskPath);
3155
+ const parsed = await readPresetForSave(sourcePath, !!name && !activePath);
3156
+ const existingMeta = parsed?.metadata ?? {};
3157
+ const variations = parsed
3158
+ ? variationsFromParsedPreset(parsed) : Array(PRESET_VARIATION_SLOTS).fill(null);
3046
3159
  variations[index] = variationString;
3047
- // 5. Build metadata: merge existing (preserves author/tags/etc.) then override with caller args
3048
3160
  const metadata = {
3049
3161
  ...existingMeta,
3050
3162
  name: presetName,
@@ -3053,12 +3165,13 @@ server.registerTool("save_variation", {
3053
3165
  currentVariation: index,
3054
3166
  ...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
3055
3167
  };
3056
- // Write to the correct subfolder path
3168
+ delete metadata.path;
3057
3169
  let resultSuffix = "";
3058
- if (activePath && !activePath.startsWith("Factory/")) {
3059
- metadata.path = activePath;
3170
+ const destinationPath = normalizePresetLookupPath(diskPath);
3171
+ if (!name && !destinationPath.startsWith("Factory/")) {
3172
+ metadata.path = destinationPath;
3060
3173
  }
3061
- else if (activePath?.startsWith("Factory/")) {
3174
+ else if (sourcePath.startsWith("Factory/")) {
3062
3175
  // Cannot write back to factory location; savePreset will create User/{name}.preset
3063
3176
  resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
3064
3177
  }
@@ -3159,7 +3272,7 @@ server.registerTool("set_bpm", {
3159
3272
  });
3160
3273
  server.registerTool("trim_sample", {
3161
3274
  title: "Trim Sample",
3162
- description: "Trim a cell's sample to its start/end markers (non-destructive crop).",
3275
+ 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
3276
  inputSchema: {
3164
3277
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3165
3278
  },
@@ -3172,7 +3285,7 @@ server.registerTool("trim_sample", {
3172
3285
  });
3173
3286
  server.registerTool("reverse_sample", {
3174
3287
  title: "Reverse Sample",
3175
- description: "Reverse a cell's loaded sample.",
3288
+ 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
3289
  inputSchema: {
3177
3290
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3178
3291
  },
@@ -3224,11 +3337,117 @@ server.registerTool("connection_status", {
3224
3337
  connectedTo: bridge.getConnectedInstance(),
3225
3338
  note: connected
3226
3339
  ? 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.`
3340
+ ? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running; use select_instance to choose; reconnects stay pinned to that instance.`
3341
+ : `Connected to ${SERVER_PRODUCT.name}. Tool availability also depends on this plugin build, open windows, and feature settings.`
3229
3342
  : "Found instances but could not connect. The plugin may have shut down.",
3230
3343
  });
3231
3344
  });
3345
+ server.registerTool("situation", {
3346
+ title: "Situation",
3347
+ description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
3348
+ "(plus build mtime and a dev-build heuristic when resolvable), server and manifest identity, active preset, " +
3349
+ "transport, and per-cell occupancy counts. Start a session here — it collapses the connection/product/build " +
3350
+ "preflight and the first design reads into one round trip. Deeper reads: get_design_overview, design_context.",
3351
+ inputSchema: {},
3352
+ annotations: { readOnlyHint: true },
3353
+ }, async () => {
3354
+ const instances = await bridge.discover().catch(() => []);
3355
+ const connected = bridge.isConnected() || (await bridge.connect().catch(() => false)) === true;
3356
+ if (connected)
3357
+ syncProductHint();
3358
+ const instance = connected ? bridge.getConnectedInstance() : null;
3359
+ let build;
3360
+ if (instance?.pid) {
3361
+ try {
3362
+ const executable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
3363
+ encoding: "utf8",
3364
+ }).trim();
3365
+ if (executable) {
3366
+ build = {
3367
+ executable,
3368
+ looksLikeDevBuild: executable.includes("/build/") && !executable.startsWith("/Applications/"),
3369
+ };
3370
+ try {
3371
+ build.executableMtime = statSync(executable).mtime.toISOString();
3372
+ }
3373
+ catch {
3374
+ /* executable path unreadable from this process */
3375
+ }
3376
+ const projectRoot = process.env.PAM_PROJECT_ROOT;
3377
+ if (projectRoot && typeof build.executableMtime === "string") {
3378
+ try {
3379
+ const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
3380
+ build.repoHeadCommitTime = headIso;
3381
+ build.binaryOlderThanHeadCommit =
3382
+ new Date(build.executableMtime).getTime() < new Date(headIso).getTime();
3383
+ }
3384
+ catch {
3385
+ /* PAM_PROJECT_ROOT is not a git checkout */
3386
+ }
3387
+ }
3388
+ }
3389
+ }
3390
+ catch {
3391
+ /* ps unavailable (Windows) or pid already gone */
3392
+ }
3393
+ }
3394
+ let live;
3395
+ let manifestName;
3396
+ if (connected) {
3397
+ const [stateRaw, transportRaw, manifest] = await Promise.all([
3398
+ bridge.send("getState").catch(() => undefined),
3399
+ bridge.send("getTransport").catch(() => undefined),
3400
+ loadManifest().catch(() => null),
3401
+ ]);
3402
+ manifestName = manifest?.name;
3403
+ const state = isRecord(stateRaw) ? stateRaw : {};
3404
+ const transport = isRecord(transportRaw) ? transportRaw : {};
3405
+ if (manifest) {
3406
+ const effectNames = cellEffectNames(manifest);
3407
+ const ids = [...new Set(manifest.parameters
3408
+ .map((p) => p.cell)
3409
+ .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
3410
+ .map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
3411
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
3412
+ const cells = ids.map((id) => {
3413
+ const prefix = `cell${id}_`;
3414
+ const rawCellState = cellsObj[String(id)];
3415
+ const cellState = isRecord(rawCellState) ? rawCellState : {};
3416
+ const samplePath = cellState.filePath ??
3417
+ cellState.samplePath ??
3418
+ state[`${prefix}filePath`] ??
3419
+ state[`${prefix}samplePath`];
3420
+ const synth = IS_MFX_SERVER ? undefined : describeSynthEngine(state[`${prefix}synthEngine`]);
3421
+ return {
3422
+ cellId: id,
3423
+ sample: stemName(samplePath),
3424
+ synthEngine: synth && synth.id > 0 ? synth.name : undefined,
3425
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
3426
+ enabledEffects: effectNames.filter((n) => state[`${prefix}${n}Enabled`] === 1 || state[`${prefix}${n}Enabled`] === true).length,
3427
+ };
3428
+ });
3429
+ live = {
3430
+ activePreset: state.activePresetPath,
3431
+ bpm: state.bpm ?? transport.bpm,
3432
+ playing: transport.playing,
3433
+ cells,
3434
+ ...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
3435
+ };
3436
+ }
3437
+ }
3438
+ return json({
3439
+ server: { name: SERVER_PRODUCT.name, version: SERVER_VERSION },
3440
+ instances,
3441
+ connected,
3442
+ instance,
3443
+ manifest: manifestName,
3444
+ build,
3445
+ live,
3446
+ note: connected
3447
+ ? undefined
3448
+ : "Not connected. connection_status has enable instructions; offline tools still work.",
3449
+ });
3450
+ });
3232
3451
  server.registerTool("select_instance", {
3233
3452
  title: "Select Plugin Instance",
3234
3453
  description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
@@ -3393,14 +3612,12 @@ function fitListing(base, itemsKey, items, moreKey, total) {
3393
3612
  }
3394
3613
  server.registerTool("list_samples", {
3395
3614
  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.",
3615
+ description: "Browse sample library directories. Library paths vary per user and platform, so take them from " +
3616
+ "get_default_locations rather than guessing.",
3399
3617
  inputSchema: {
3400
3618
  path: z
3401
3619
  .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."),
3620
+ .describe("Directory path to list: a path from get_default_locations, or a subdirectory of one."),
3404
3621
  audioOnly: z
3405
3622
  .boolean()
3406
3623
  .default(true)
@@ -3489,8 +3706,9 @@ server.registerTool("get_audio_info", {
3489
3706
  });
3490
3707
  server.registerTool("export_preset", {
3491
3708
  title: "Export Preset",
3492
- description: "Export a preset as a zip file bundled with all referenced samples. " +
3493
- "Creates a shareable preset package.",
3709
+ description: "Export a preset as a zip file bundled with all referenced samples, written to PAM's Exports folder as " +
3710
+ "<outputFolderName>.zip (get_default_locations resolves that folder). An existing folder or zip of that " +
3711
+ "name is replaced, and presets belonging to a preset pack are refused.",
3494
3712
  inputSchema: {
3495
3713
  presetPath: z
3496
3714
  .string()
@@ -3508,7 +3726,10 @@ server.registerTool("export_preset", {
3508
3726
  });
3509
3727
  server.registerTool("import_preset", {
3510
3728
  title: "Import Preset",
3511
- description: "Import a preset from a zip file or folder into PAM's preset library.",
3729
+ description: "Import a preset from a zip file or folder into PAM's preset library. Returns the relative path of the " +
3730
+ "first imported preset, or an empty string on failure. Referenced samples are copied into the user sample " +
3731
+ "folder and the preset's paths are rewritten. A name already in the library is never overwritten: the " +
3732
+ "import lands as <name>_1, <name>_2 and so on.",
3512
3733
  inputSchema: {
3513
3734
  path: z.string().describe("Absolute path to the preset zip file or folder to import."),
3514
3735
  },
@@ -3718,7 +3939,7 @@ server.registerTool("capture_ui", {
3718
3939
  "PresetBrowser; omit/null = cell grid), instrumentView ('synth'|'sampler', applied to " +
3719
3940
  "all cells). REQUIRES PAM's window OPEN — html2canvas needs the live DOM, there is no " +
3720
3941
  "headless renderer (same constraint as the video tools). Beats screen-capture: " +
3721
- "deterministic, no cursor/screen control, exact UI bitmap.",
3942
+ "deterministic, no cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is not restored.",
3722
3943
  inputSchema: {
3723
3944
  gridSize: z.number().optional().describe("Cell grid size to set before capture: 1, 4, or 8."),
3724
3945
  mainView: z
@@ -3732,7 +3953,7 @@ server.registerTool("capture_ui", {
3732
3953
  settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
3733
3954
  timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
3734
3955
  },
3735
- annotations: { readOnlyHint: true },
3956
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
3736
3957
  }, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
3737
3958
  const check = await requireBridge();
3738
3959
  if (check !== true)
@@ -4112,13 +4333,23 @@ server.registerTool("set_video_settings", {
4112
4333
  "loads on the next bar boundary while playing). resetWorkspace clears the " +
4113
4334
  "scene (preserves the master layer).",
4114
4335
  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(),
4336
+ syncMode: z
4337
+ .enum(["off", "beat-sync", "beat-match"])
4338
+ .optional()
4339
+ .describe("Clock source: 'off' free-runs, 'beat-sync' locks playback to the bar grid, 'beat-match' retimes clips to the transport."),
4340
+ loopBars: z.number().min(0).optional().describe("Loop length in bars for the beat-sync and beat-match modes."),
4341
+ aspectRatio: z
4342
+ .enum(["auto", "16:9", "4:3", "1:1", "stretch"])
4343
+ .optional()
4344
+ .describe("How a layer is fitted to the canvas. 'auto' keeps the source ratio, 'stretch' fills it."),
4345
+ renderQuality: z.enum(["low", "medium", "high"]).optional().describe("VX render resolution/effort tier."),
4346
+ designAspect: z
4347
+ .number()
4348
+ .nullable()
4349
+ .optional()
4350
+ .describe("Canvas design ratio as width/height (e.g. 1.7777 for 16:9). null clears it and follows the window."),
4351
+ letterboxColor: z.string().describe("#hex fill for the bars outside the design aspect.").optional(),
4352
+ canvasColor: z.string().describe("#hex background behind every VX layer.").optional(),
4122
4353
  modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
4123
4354
  vxVariationFollowAudio: z
4124
4355
  .boolean()
@@ -4240,8 +4471,9 @@ const PATTERN_SUFFIXES = [
4240
4471
  ];
4241
4472
  server.registerTool("reset_cell", {
4242
4473
  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.",
4474
+ description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
4475
+ "Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
4476
+ "the cell config block (update_cell_config) and the loaded sample. Does not touch other cells.",
4245
4477
  inputSchema: {
4246
4478
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4247
4479
  },
@@ -4274,8 +4506,9 @@ server.registerTool("reset_cell", {
4274
4506
  });
4275
4507
  server.registerTool("copy_cell", {
4276
4508
  title: "Copy Cell",
4277
- description: "Copy all parameters and modulation from one cell to another. " +
4278
- "Overwrites the target cell's settings.",
4509
+ description: "Copy a cell's manifest parameters and remap its LFO, envelope and vary links onto another cell, overwriting the target's. " +
4510
+ "Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
4511
+ "sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
4279
4512
  inputSchema: {
4280
4513
  sourceCellId: z.number().min(1).max(8).describe("Source cell (1-8)."),
4281
4514
  targetCellId: z.number().min(1).max(8).describe("Target cell (1-8)."),
@@ -4385,7 +4618,7 @@ server.registerTool("copy_cell", {
4385
4618
  server.registerTool("clear_modulations", {
4386
4619
  title: "Clear Modulations",
4387
4620
  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.",
4621
+ "(or originating from that cell, for envelopes/vary). Parameter sequencers survive — use remove_param_seq for those.",
4389
4622
  inputSchema: {
4390
4623
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4391
4624
  },
@@ -4530,7 +4763,7 @@ server.registerTool("update_effect_config", {
4530
4763
  // unreachable through set_parameters (numbers only) — an agent could shape an MFX
4531
4764
  // cell but never patch one to another, which is the whole product.
4532
4765
  //
4533
- // Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
4766
+ // Validation mirrors parseRoutingEdges (interface/src/dsp/products/mfx/mfxRouting.ts): the
4534
4767
  // plugin re-validates on receipt, so this pass exists to fail loudly here rather
4535
4768
  // than have a typo silently drop a cable.
4536
4769
  const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
@@ -4634,7 +4867,7 @@ server.registerTool("set_mfx_edge_gain", {
4634
4867
  description: "Read or set the gain for one cable in the current MFX patch. Gain is linear: 0 is silent, 1 is unity, " +
4635
4868
  "and 1.5 is the maximum. The cable must already exist. Other cable gains are preserved.",
4636
4869
  inputSchema: {
4637
- op: z.enum(["get", "set"]),
4870
+ op: z.enum(["get", "set"]).describe("'get' returns every cable gain; 'set' writes one."),
4638
4871
  from: z.string().optional().describe(`Cable source for 'set': ${MFX_ROUTING_SOURCE_HINT}`),
4639
4872
  to: z.string().optional().describe(`Cable destination for 'set': ${MFX_ROUTING_SINK_HINT}`),
4640
4873
  gain: z.number().min(0).max(1.5).optional().describe("Linear gain for 'set'."),
@@ -4680,9 +4913,12 @@ server.registerTool("configure_mfx_combo", {
4680
4913
  op: z.enum([
4681
4914
  "get", "setValue", "rename", "lock", "unlock", "rescan", "setTargets", "addTarget",
4682
4915
  "removeTarget", "clearTargets", "invertTarget", "setTargetStop", "anchor",
4683
- ]),
4916
+ ]).describe("get reads the combo; setValue writes its value and every resolved target; rename relabels it (blank restores the role label); " +
4917
+ "lock freezes the current automatic assignment as manual; unlock and rescan return it to automatic; " +
4918
+ "setTargets/addTarget/removeTarget/clearTargets edit the manual target list; invertTarget swaps a target's from/to stops; " +
4919
+ "setTargetStop sets one target's stop value; anchor re-derives every target's stops from the current parameter values."),
4684
4920
  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."),
4921
+ value: z.number().optional().describe("Combo value (−100 to 100) for setValue, or a value in the target parameter's units for setTargetStop."),
4686
4922
  name: z.string().max(24).optional().describe("New label for rename; blank restores the role label."),
4687
4923
  paramId: z.string().optional().describe("Target parameter for add/remove/invert/setTargetStop."),
4688
4924
  stop: z.enum(["from", "mid", "to"]).optional().describe("Target stop edited by setTargetStop."),
@@ -4707,6 +4943,8 @@ server.registerTool("configure_mfx_combo", {
4707
4943
  if (op === "setValue") {
4708
4944
  if (typeof value !== "number")
4709
4945
  return error("setValue requires value.");
4946
+ if (value < -100 || value > 100)
4947
+ return error("Combo value must be between -100 and 100.");
4710
4948
  const delta = { [`mfx_combo${index}`]: value };
4711
4949
  for (const target of combo.targets) {
4712
4950
  const info = parameterIndex.get(target.id);
@@ -4923,8 +5161,8 @@ server.registerTool("update_cell_config", {
4923
5161
  "An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
4924
5162
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
4925
5163
  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."),
5164
+ stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=Default (OLA), 1=Cloud, 2=Spectral, 3=Beats."),
5165
+ 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
5166
  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
5167
  grainDensity: z.number().int().min(1).max(24).optional().describe("Cloud stretch grain density when stretchMode=1."),
4930
5168
  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."),
@@ -5002,7 +5240,7 @@ server.registerTool("set_cell_mute", {
5002
5240
  description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
5003
5241
  "(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
5004
5242
  "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'.",
5243
+ "Prefer this over pulling cell{N}_volume down: a fader move leaves effect tails audible, a channel mute cuts them.",
5006
5244
  inputSchema: {
5007
5245
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
5008
5246
  mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
@@ -5043,11 +5281,7 @@ server.registerTool("set_master_effects", {
5043
5281
  "Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
5044
5282
  "The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
5045
5283
  "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). " +
5284
+ "Pass enabled flags to toggle each on/off. Pass parameters to shape the sound; genre recipes live in the beat-making-guide resource. " +
5051
5285
  "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
5286
  "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
5287
  inputSchema: {
@@ -5081,7 +5315,7 @@ server.registerTool("set_master_effects", {
5081
5315
  masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
5082
5316
  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
5317
  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."),
5318
+ 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
5319
  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
5320
  masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
5087
5321
  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."),
@@ -5262,7 +5496,7 @@ server.registerTool("set_drum_synth_wave", {
5262
5496
  "centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
5263
5497
  "that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
5264
5498
  "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.",
5499
+ "is single-sourced there), so PAM's window must be OPEN with drum-synth wave editing support.",
5266
5500
  inputSchema: {
5267
5501
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
5268
5502
  points: z
@@ -5293,6 +5527,394 @@ server.registerTool("set_drum_synth_wave", {
5293
5527
  return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
5294
5528
  `Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
5295
5529
  });
5530
+ // Generative Players (per-cell PLAYER mode). Arming semantics mirror
5531
+ // interface/src/interop.ts setCellPlayer; the registry is shared/generativePlayers.json.
5532
+ const PLAYER_REF_HELP = `one of ${PLAYER_NAMES.join(", ")} (or its id 1..${GENERATIVE_PLAYERS.length}), ` +
5533
+ "'role' for the cell's role default, or 'off'";
5534
+ function playerProductError(manifest) {
5535
+ if (manifest && manifest.generativePatterns?.enabled !== true) {
5536
+ return error(`Generative players are PAM-only. ${manifest.name ?? "This product"}'s manifest exposes none.`);
5537
+ }
5538
+ return null;
5539
+ }
5540
+ const playerSlotArg = (n) => z
5541
+ .union([z.number().min(0).max(1), z.string()])
5542
+ .optional()
5543
+ .describe(`P${n} slot: 0..1 (snapped to the slot's detents) or a position label such as 'Techno' or 'Triad'. ` +
5544
+ "Its meaning is per player — list_players names it.");
5545
+ server.registerTool("list_players", {
5546
+ title: "List Generative Players",
5547
+ description: "Catalogue of PAM's generative Players — per-cell generators that replace a cell's drawn pattern with a " +
5548
+ "seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK). Returns each " +
5549
+ "player's blurb, defaults, P-slot vocabulary (detented positions or 0..1 knobs) and curated preset names, plus " +
5550
+ "the role spread (which player each cell arms by default). Offline — no bridge needed. Arm one with set_player.",
5551
+ inputSchema: {
5552
+ player: z.string().optional().describe("Return one player's full detail (name or id). Omit for the whole roster."),
5553
+ presets: z
5554
+ .boolean()
5555
+ .optional()
5556
+ .describe("Include each curated preset's values, not only its name. Default false."),
5557
+ },
5558
+ annotations: { readOnlyHint: true },
5559
+ }, async ({ player, presets }) => {
5560
+ const manifest = await loadManifestForProductGuard();
5561
+ const available = manifest ? manifest.generativePatterns?.enabled === true : true;
5562
+ const spec = player ? resolvePlayerSpec(player) : undefined;
5563
+ if (player && !spec)
5564
+ return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
5565
+ const specs = spec ? [spec] : GENERATIVE_PLAYERS;
5566
+ return json({
5567
+ available,
5568
+ ...(available ? {} : { note: `${manifest?.name ?? "This product"} exposes no players; the catalogue is informational.` }),
5569
+ players: specs.map((entry) => ({
5570
+ ...describePlayerSpec(entry, true),
5571
+ ...(presets ? { presetValues: playerPresetsFor(entry.id) } : {}),
5572
+ })),
5573
+ roleSpread: roleSpreadSummary(),
5574
+ notes: [
5575
+ "A player replaces what the cell PLAYS, never the drawn pattern: set_player 'off' restores the grid exactly.",
5576
+ "Sequencer output needs the transport running (start_transport).",
5577
+ "Melodic players arm the cell poly with the root on its trigger note; 'off' restores both.",
5578
+ "KIT follows its Genre slot — pass genre to set_player so it matches the track.",
5579
+ "patternMode 'keep' layers the player over the drawn pattern instead of masking it.",
5580
+ ],
5581
+ });
5582
+ });
5583
+ server.registerTool("set_player", {
5584
+ title: "Set Generative Player",
5585
+ description: "Arm, edit or switch off a cell's generative Player (PAM-only). Passing `player` arms that generator with the " +
5586
+ "roster's own voicing (or a curated `preset`), the track genre on genre-aware players, and a reversible poly " +
5587
+ "arm on melodic ones; omit `player` to edit the running player's values in place. Values land in both the cell " +
5588
+ "config and the cell{N}_player* automation params, like the plugin UI. 'off' restores the drawn pattern and the " +
5589
+ "cell's pre-arm polyphony. The player sounds only while the transport runs. The UI shows a running player " +
5590
+ "unprompted; to arm from the UI as well, update_settings { experimentalMode: true, experimentalGenerativePatterns: true }.",
5591
+ inputSchema: {
5592
+ cellIds: z
5593
+ .array(z.number().int().min(1).max(8))
5594
+ .min(1)
5595
+ .describe("Cells to write (1-8). Several cells take the same write, each snapped to its own player."),
5596
+ player: z
5597
+ .union([z.string(), z.number().int().min(0)])
5598
+ .optional()
5599
+ .describe(`Player to arm: ${PLAYER_REF_HELP}. Omit to edit the running player.`),
5600
+ preset: z.string().optional().describe("Curated preset name for the armed or running player — list_players lists them."),
5601
+ genre: z
5602
+ .enum(PLAYER_GENRES)
5603
+ .optional()
5604
+ .describe("Genre for genre-aware players (KIT's Genre slot). Defaults to the roster default when arming."),
5605
+ density: z.number().min(0).max(1).optional().describe("How much plays, 0..1. Audible from the next step."),
5606
+ mutate: z.number().min(0).max(1).optional().describe("Cycle-to-cycle evolution, 0..1 (below 0.02 locks the loop)."),
5607
+ p1: playerSlotArg(1),
5608
+ p2: playerSlotArg(2),
5609
+ p3: playerSlotArg(3),
5610
+ p4: playerSlotArg(4),
5611
+ p5: playerSlotArg(5),
5612
+ seed: z
5613
+ .union([z.number().int().min(0), z.literal("random")])
5614
+ .optional()
5615
+ .describe("Player seed (uint31) or 'random' to reroll — the DICE gesture."),
5616
+ patternMode: z
5617
+ .enum(PATTERN_MODES)
5618
+ .optional()
5619
+ .describe("'mask' (default): the player replaces the drawn pattern. 'keep': the drawn pattern keeps sounding and the player layers over it."),
5620
+ listenMode: z
5621
+ .enum(LISTEN_MODES)
5622
+ .optional()
5623
+ .describe("Ensemble listening: react to another cell's last completed cycle — complement its gaps, answer it, or shade velocity harmonically."),
5624
+ listenSource: z.number().int().min(0).max(8).optional().describe("Listening source: 0 = all other cells, 1-8 = one cell (never itself)."),
5625
+ listenAmount: z.number().min(0).max(1).optional().describe("Listening strength 0..1."),
5626
+ poly: z
5627
+ .boolean()
5628
+ .optional()
5629
+ .describe("Override the automatic poly arm: true forces poly, false keeps the cell mono even for a melodic player."),
5630
+ },
5631
+ }, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, listenMode, listenSource, listenAmount, poly }) => {
5632
+ const check = await requireBridgeForCells(cellIds);
5633
+ if (check !== true)
5634
+ return check;
5635
+ const manifest = await loadManifestForProductGuard();
5636
+ const gate = playerProductError(manifest);
5637
+ if (gate)
5638
+ return gate;
5639
+ const playerRef = typeof player === "string" ? player.trim().toLowerCase() : player;
5640
+ const wantsOff = playerRef === "off" || playerRef === 0;
5641
+ const wantsRole = playerRef === "role";
5642
+ const explicitSpec = player !== undefined && !wantsOff && !wantsRole ? resolvePlayerSpec(player) : undefined;
5643
+ if (player !== undefined && !wantsOff && !wantsRole && !explicitSpec) {
5644
+ return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
5645
+ }
5646
+ const stateRaw = await bridge.send("getState");
5647
+ const state = isRecord(stateRaw) ? stateRaw : {};
5648
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
5649
+ const slotInputs = [p1, p2, p3, p4, p5];
5650
+ const flatDelta = {};
5651
+ const cellsDelta = {};
5652
+ const lines = [];
5653
+ for (const cellId of new Set(cellIds)) {
5654
+ const rawCell = cellsObj[String(cellId)];
5655
+ const cell = isRecord(rawCell) ? rawCell : {};
5656
+ const currentType = cellPlayerType(cell);
5657
+ const recorded = (key) => {
5658
+ const value = Number(cell[key]);
5659
+ return Number.isFinite(value) && value !== NO_POLY_ARM ? Math.round(value) : undefined;
5660
+ };
5661
+ if (wantsOff) {
5662
+ if (currentType <= 0) {
5663
+ lines.push(`Cell ${cellId}: no player running.`);
5664
+ continue;
5665
+ }
5666
+ const config = { playerType: 0, playerLastType: currentType };
5667
+ const prePoly = recorded("playerPrePoly");
5668
+ const preRoot = recorded("playerPreRoot");
5669
+ if (prePoly !== undefined)
5670
+ config.isPoly = prePoly;
5671
+ if (preRoot !== undefined)
5672
+ config.polyRootNote = preRoot;
5673
+ config.playerPrePoly = NO_POLY_ARM;
5674
+ config.playerPreRoot = NO_POLY_ARM;
5675
+ cellsDelta[String(cellId)] = config;
5676
+ lines.push(`Cell ${cellId}: ${getPlayerSpec(currentType)?.name ?? "player"} off — drawn pattern restored` +
5677
+ `${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}.`);
5678
+ continue;
5679
+ }
5680
+ const roleConfig = wantsRole ? rolePlayerConfigFor(cellId) : undefined;
5681
+ const spec = explicitSpec ?? (roleConfig ? getPlayerSpec(roleConfig.playerType) : getPlayerSpec(currentType));
5682
+ if (!spec)
5683
+ return error(`Cell ${cellId} runs no player. Pass player to arm one — ${PLAYER_REF_HELP}.`);
5684
+ const arming = explicitSpec !== undefined || roleConfig !== undefined;
5685
+ const config = {};
5686
+ if (arming) {
5687
+ Object.assign(config, playerDefaultsFor(spec.id), playerGenreConfig(spec, genre));
5688
+ if (roleConfig?.playerP1 !== undefined)
5689
+ config.playerP1 = roleConfig.playerP1;
5690
+ if (roleConfig?.playerP5 !== undefined)
5691
+ config.playerP5 = roleConfig.playerP5;
5692
+ config.playerLastType = spec.id;
5693
+ }
5694
+ else if (genre) {
5695
+ Object.assign(config, playerGenreConfig(spec, genre));
5696
+ }
5697
+ if (preset) {
5698
+ const found = resolvePlayerPreset(spec, preset);
5699
+ if (!found) {
5700
+ const names = playerPresetsFor(spec.id).map((entry) => entry.name);
5701
+ return error(`${spec.name} has no preset '${preset}' — one of: ${names.join(", ")}.`);
5702
+ }
5703
+ Object.assign(config, presetConfig(found));
5704
+ }
5705
+ if (density !== undefined)
5706
+ config.playerDensity = density;
5707
+ if (mutate !== undefined)
5708
+ config.playerMutate = mutate;
5709
+ for (let i = 0; i < slotInputs.length; i++) {
5710
+ const input = slotInputs[i];
5711
+ if (input === undefined)
5712
+ continue;
5713
+ const slot = spec.slots[i];
5714
+ if (!slot)
5715
+ return error(`${spec.name} has no P${i + 1} slot (it has ${spec.slots.length}).`);
5716
+ const resolved = resolveSlotValue(slot, input);
5717
+ if (typeof resolved === "string")
5718
+ return error(`Cell ${cellId} ${spec.name} P${i + 1}: ${resolved}`);
5719
+ config[PLAYER_SLOT_FIELDS[i]] = resolved;
5720
+ }
5721
+ PLAYER_SLOT_FIELDS.forEach((field, i) => {
5722
+ if (config[field] !== undefined)
5723
+ config[field] = snapPlayerValue(spec.slots[i]?.steps, config[field]);
5724
+ });
5725
+ if (seed !== undefined)
5726
+ config.playerSeed = seed === "random" ? randomPlayerSeed() : Math.round(seed);
5727
+ if (patternMode)
5728
+ config.playerPatternMode = PATTERN_MODES.indexOf(patternMode);
5729
+ if (listenMode)
5730
+ config.playerListenMode = LISTEN_MODES.indexOf(listenMode);
5731
+ if (listenSource !== undefined)
5732
+ config.playerListenSource = listenSource;
5733
+ if (listenAmount !== undefined)
5734
+ config.playerListenAmount = listenAmount;
5735
+ // Reversible poly arm: record the pre-arm values once, never the armed state.
5736
+ const autoPoly = arming && poly !== false && (isMelodicPlayer(spec) || roleConfig?.isPoly === 1);
5737
+ let polyNote = "";
5738
+ if (autoPoly) {
5739
+ if (recorded("playerPrePoly") === undefined && recorded("playerPreRoot") === undefined) {
5740
+ config.playerPrePoly = Math.round(Number(cell.isPoly ?? 0)) || 0;
5741
+ config.playerPreRoot = Math.round(Number(cell.polyRootNote ?? 48)) || 48;
5742
+ }
5743
+ config.isPoly = 1;
5744
+ config.polyRootNote = Math.round(Number(cell.midiNote ?? 47 + cellId)) || 47 + cellId;
5745
+ polyNote = `; poly armed (root ${config.polyRootNote})`;
5746
+ }
5747
+ else if (poly !== undefined) {
5748
+ config.isPoly = poly ? 1 : 0;
5749
+ polyNote = `; poly ${poly ? "on" : "off"}`;
5750
+ }
5751
+ if (arming)
5752
+ config.playerType = spec.id;
5753
+ if (Object.keys(config).length === 0)
5754
+ return error("Nothing to write — pass player, preset, or at least one value.");
5755
+ cellsDelta[String(cellId)] = config;
5756
+ for (const field of MODULATABLE_PLAYER_FIELDS) {
5757
+ if (typeof config[field] === "number")
5758
+ flatDelta[`cell${cellId}_${field}`] = config[field];
5759
+ }
5760
+ const parts = [];
5761
+ if (config.playerDensity !== undefined)
5762
+ parts.push(`density ${playerDensityDisplay(config.playerDensity)}`);
5763
+ if (config.playerMutate !== undefined)
5764
+ parts.push(`mutate ${playerMutateDisplay(config.playerMutate)}`);
5765
+ spec.slots.forEach((slot, i) => {
5766
+ const value = config[PLAYER_SLOT_FIELDS[i]];
5767
+ if (value !== undefined)
5768
+ parts.push(`${slot.label}=${playerSlotDisplay(slot, value)}`);
5769
+ });
5770
+ if (config.playerSeed !== undefined)
5771
+ parts.push(`seed ${config.playerSeed}`);
5772
+ if (patternMode)
5773
+ parts.push(`pattern ${patternMode}`);
5774
+ if (listenMode)
5775
+ parts.push(`listen ${listenMode}`);
5776
+ lines.push(`Cell ${cellId}: ${spec.name} ${arming ? "armed" : "updated"} — ${parts.join(", ")}${polyNote}.`);
5777
+ }
5778
+ if (Object.keys(cellsDelta).length === 0)
5779
+ return text(lines.join("\n"));
5780
+ await applyKv({ ...flatDelta, cells: cellsDelta }, KV_OPTS_CELL_CONFIG);
5781
+ return text(`${lines.join("\n")}\n` +
5782
+ "Players sound only while the transport runs; get_design_overview reports each cell's player.");
5783
+ });
5784
+ /** A cell's sample as a readable file: absolute as given, else relative to a library root. */
5785
+ async function resolveCellSampleFile(filePath) {
5786
+ const expanded = expandHome(filePath);
5787
+ if (isAbsolute(expanded))
5788
+ return existsSync(expanded) ? expanded : undefined;
5789
+ const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
5790
+ const roots = Array.isArray(locations)
5791
+ ? locations.filter((entry) => isRecord(entry) && typeof entry.path === "string")
5792
+ : [];
5793
+ const normalized = filePath.replaceAll("\\", "/");
5794
+ const candidates = [];
5795
+ for (const root of roots) {
5796
+ const stripped = normalized.replace(/^Samples\/(User|Factory)\//, "");
5797
+ if (/user/i.test(root.name) && /^Samples\/User\//.test(normalized))
5798
+ candidates.push(join(root.path, stripped));
5799
+ if (/factory/i.test(root.name) && /^Samples\/Factory\//.test(normalized))
5800
+ candidates.push(join(root.path, stripped));
5801
+ candidates.push(join(root.path, normalized), join(root.path, stripped));
5802
+ }
5803
+ return candidates.find((candidate) => existsSync(candidate));
5804
+ }
5805
+ server.registerTool("set_slices", {
5806
+ title: "Set Sample Slices",
5807
+ description: "Slice the sample loaded in a cell: explicit `boundaries` (0..1 of the file), `count` equal slices, or " +
5808
+ "`transients` detected from the audio (WAV files, offline). Writes cell{N}_sliceBoundaries / _sliceCount — " +
5809
+ "what the slice lane, Slice Keymap, preview_slice and export_slice all read — and switches the Slice knob on. " +
5810
+ "`sequence:true` also chops the loop into the slice + note lanes so it plays back in order at native rate " +
5811
+ "(the Slice → Sequence gesture: modLatch on, both lanes at 1× over the loop's length in beats).",
5812
+ inputSchema: {
5813
+ cellId: z.number().int().min(1).max(8).describe("Cell number (1-8); it must have a sample loaded."),
5814
+ boundaries: z
5815
+ .array(z.number().min(0).max(1))
5816
+ .min(2)
5817
+ .max(MAX_SLICES + 1)
5818
+ .optional()
5819
+ .describe("Slice boundaries as 0..1 positions in the file, ascending; 0 and 1 are added when missing."),
5820
+ count: z.number().int().min(1).max(MAX_SLICES).optional().describe(`Equal slices, 1..${MAX_SLICES}.`),
5821
+ transients: z.boolean().optional().describe("Detect onsets in the sample and slice there (WAV only)."),
5822
+ sensitivity: z.number().min(0).max(1).optional().describe("Transient sensitivity 0..1 (default 0.5; higher finds more onsets)."),
5823
+ maxSlices: z.number().int().min(2).max(MAX_SLICES).optional().describe("Cap on transient slices (default 32)."),
5824
+ sequence: z.boolean().optional().describe("Also write the slice + note lanes so the slices play in order (loops)."),
5825
+ },
5826
+ }, async ({ cellId, boundaries, count, transients, sensitivity, maxSlices, sequence }) => {
5827
+ const check = await requireBridgeForCells(cellId);
5828
+ if (check !== true)
5829
+ return check;
5830
+ const manifest = await loadManifestForProductGuard();
5831
+ if (manifest && isMfxManifest(manifest))
5832
+ return error("set_slices needs a sampler cell; MFX cells carry no sample.");
5833
+ const modes = [boundaries !== undefined, count !== undefined, transients === true].filter(Boolean).length;
5834
+ if (modes !== 1)
5835
+ return error("Pass exactly one of boundaries, count, or transients:true.");
5836
+ const stateRaw = await bridge.send("getState");
5837
+ const state = isRecord(stateRaw) ? stateRaw : {};
5838
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
5839
+ const cell = isRecord(cellsObj[String(cellId)]) ? cellsObj[String(cellId)] : {};
5840
+ const filePath = [cell.filePath, cell.samplePath, state[`cell${cellId}_filePath`]].find((v) => typeof v === "string" && v.length > 0);
5841
+ if (!filePath)
5842
+ return error(`Cell ${cellId} has no sample loaded — load_sample first.`);
5843
+ let resolved;
5844
+ let wavDuration;
5845
+ if (boundaries)
5846
+ resolved = normalizeBoundaries(boundaries);
5847
+ else if (count !== undefined)
5848
+ resolved = equalBoundaries(count);
5849
+ else {
5850
+ const file = await resolveCellSampleFile(filePath);
5851
+ if (!file)
5852
+ return error(`Could not find '${filePath}' on disk to analyse — pass count or boundaries instead.`);
5853
+ let wav;
5854
+ try {
5855
+ wav = decodeWav(await readFile(file));
5856
+ }
5857
+ catch (e) {
5858
+ return error(`Transient slicing reads WAV files only (${e.message}) — pass count or boundaries for ${basename(file)}.`);
5859
+ }
5860
+ wavDuration = wav.durationSeconds;
5861
+ resolved = detectOnsetBoundaries(wav, sensitivity ?? 0.5, maxSlices ?? 32);
5862
+ if (resolved.length < 3)
5863
+ return error("No transients found above the threshold — raise sensitivity or pass count.");
5864
+ }
5865
+ const sliceCount = resolved.length - 1;
5866
+ const currentKnob = Number(state[`cell${cellId}_slice`]) || 0;
5867
+ const delta = {
5868
+ [`cell${cellId}_sliceBoundaries`]: resolved,
5869
+ [`cell${cellId}_sliceCount`]: sliceCount,
5870
+ };
5871
+ if (currentKnob < 1 || currentKnob > sliceCount)
5872
+ delta[`cell${cellId}_slice`] = 1;
5873
+ await applyKv(delta, KV_OPTS_CELL_CONFIG);
5874
+ let sequenceNote = "";
5875
+ if (sequence) {
5876
+ const transportRaw = await bridge.send("getTransport").catch(() => undefined);
5877
+ const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
5878
+ let duration = wavDuration;
5879
+ if (duration === undefined) {
5880
+ const info = await bridge.send("getAudioFileInfo", filePath).catch(() => undefined);
5881
+ const parsed = typeof info === "string" ? JSON.parse(info) : info;
5882
+ duration = [parsed?.duration, parsed?.durationSeconds, parsed?.lengthSeconds, parsed?.length]
5883
+ .map(Number)
5884
+ .find((v) => Number.isFinite(v) && v > 0);
5885
+ }
5886
+ const loopBeats = duration ? (duration * bpm) / 60 : Number(state[`cell${cellId}_patternLengthSlice`]) || 16;
5887
+ const patternLength = Math.max(1, Math.min(32, duration ? Math.ceil(loopBeats) : loopBeats));
5888
+ const note = typeof cell.midiNote === "number" ? cell.midiNote : 48 + cellId - 1;
5889
+ const channel = typeof cell.midiChannel === "number" ? cell.midiChannel : 1;
5890
+ const sliceEvents = [];
5891
+ const noteEvents = [];
5892
+ for (let i = 0; i < sliceCount; i++) {
5893
+ const time = resolved[i] * loopBeats;
5894
+ const dur = (resolved[i + 1] - resolved[i]) * loopBeats;
5895
+ if (dur <= 0)
5896
+ continue;
5897
+ sliceEvents.push({ note, channel, time, duration: dur, velocity: 100, value: i + 1 });
5898
+ noteEvents.push({ note, channel, time, duration: dur, velocity: 100 });
5899
+ }
5900
+ await applyKv({
5901
+ [`cell${cellId}_sliceSequence`]: sliceEvents,
5902
+ [`cell${cellId}_midiSequence`]: noteEvents,
5903
+ [`cell${cellId}_patternLengthSlice`]: patternLength,
5904
+ [`cell${cellId}_patternLength`]: patternLength,
5905
+ [`cell${cellId}_patternSpeedSlice`]: 9,
5906
+ [`cell${cellId}_patternSpeed`]: 9,
5907
+ [`cell${cellId}_patternDirection`]: 0,
5908
+ [`cell${cellId}_slice`]: 1,
5909
+ cells: { [cellId]: { modLatch: 1 } },
5910
+ __forceRender__: true,
5911
+ }, KV_OPTS_PATTERN);
5912
+ sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
5913
+ }
5914
+ const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
5915
+ return text(`Cell ${cellId}: ${sliceCount} slice${sliceCount === 1 ? "" : "s"}` +
5916
+ `${positions.length ? ` (cuts at ${positions.join(", ")})` : ""}.${sequenceNote}`);
5917
+ });
5296
5918
  server.registerTool("add_param_seq", {
5297
5919
  title: "Add Parameter Sequencer",
5298
5920
  description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
@@ -5309,8 +5931,16 @@ server.registerTool("add_param_seq", {
5309
5931
  bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
5310
5932
  length: z.number().min(0.0625).max(16).default(1).describe("Pattern length in bars."),
5311
5933
  speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
5934
+ freeMode: z
5935
+ .union([z.literal(0), z.literal(1)])
5936
+ .default(0)
5937
+ .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)."),
5938
+ stepMode: z
5939
+ .union([z.literal(0), z.literal(1)])
5940
+ .default(0)
5941
+ .describe("0 = Clock; 1 = Gate: advance one step per trigger of the source cell (cell-locked only; MFX has no Gate)."),
5312
5942
  },
5313
- }, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
5943
+ }, async ({ paramId, cellId, sequence, strength, bipolar, length, speed, freeMode, stepMode }) => {
5314
5944
  const check = await requireBridgeForCells(cellId);
5315
5945
  if (check !== true)
5316
5946
  return check;
@@ -5333,7 +5963,8 @@ server.registerTool("add_param_seq", {
5333
5963
  bipolar,
5334
5964
  boost: 0,
5335
5965
  interpolate: 0,
5336
- freeMode: 0,
5966
+ freeMode,
5967
+ stepMode,
5337
5968
  };
5338
5969
  paramSeqs.push(newSeq);
5339
5970
  await applyKv({ paramSeqs }, KV_OPTS_MOD);
@@ -5346,11 +5977,19 @@ server.registerTool("update_param_seq", {
5346
5977
  inputSchema: {
5347
5978
  paramId: z.string().describe("Target parameter ID."),
5348
5979
  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(),
5980
+ strength: z.number().min(0).max(1).optional().describe("Modulation depth (0-1)."),
5981
+ length: z.number().min(0.0625).max(16).optional().describe("Pattern length in bars."),
5982
+ speed: z.number().min(0.0625).max(16).optional().describe("Pattern speed multiplier."),
5983
+ bipolar: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = unipolar, 1 = bipolar."),
5984
+ enabled: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = bypass the sequencer, 1 = run it."),
5985
+ freeMode: z
5986
+ .union([z.literal(0), z.literal(1)])
5987
+ .optional()
5988
+ .describe("0 = cell-locked with its own steps; 1 = mapped to the Free Sequencer master contour (set_free_sequencer)."),
5989
+ stepMode: z
5990
+ .union([z.literal(0), z.literal(1)])
5991
+ .optional()
5992
+ .describe("0 = Clock; 1 = Gate (one step per source-cell trigger; falls back to Clock when free or on MFX)."),
5354
5993
  },
5355
5994
  }, async ({ paramId, ...updates }) => {
5356
5995
  const check = await requireBridge();
@@ -5374,6 +6013,105 @@ server.registerTool("update_param_seq", {
5374
6013
  await applyKv({ paramSeqs }, KV_OPTS_MOD);
5375
6014
  return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
5376
6015
  });
6016
+ server.registerTool("set_free_sequencer", {
6017
+ title: "Set Free Sequencer",
6018
+ description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..64 steps of 0..1). Every " +
6019
+ "parameter sequencer mapped to it (freeMode:1 via add_param_seq / update_param_seq) follows these steps AND its " +
6020
+ "clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
6021
+ "their own steps. An empty contour restores the legacy per-sequencer behaviour.",
6022
+ inputSchema: {
6023
+ sequence: z.array(z.number().min(0).max(1)).min(2).max(64).describe("Master step values, 0..1 each."),
6024
+ },
6025
+ }, async ({ sequence }) => {
6026
+ const check = await requireBridge();
6027
+ if (check !== true)
6028
+ return check;
6029
+ await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
6030
+ return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
6031
+ });
6032
+ server.registerTool("export_midi", {
6033
+ title: "Export MIDI",
6034
+ description: "Write the cells' drawn note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): " +
6035
+ "one track per cell on MIDI channel = cell number, each pattern tiled across `bars`. Pure file write — no " +
6036
+ "render, nothing changes in PAM. Generated Player cycles are not drawn patterns; FREEZE them first (in the UI) " +
6037
+ "to export what a player is playing. Default location is PAM's Exports folder.",
6038
+ inputSchema: {
6039
+ filename: z.string().optional().describe("Base name without extension (default pam_midi_<epoch>)."),
6040
+ cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes)."),
6041
+ bars: z.number().min(1).max(64).optional().describe("Length in 4/4 bars (default: the longest included pattern cycle, at least 1)."),
6042
+ mode: z.enum(["combined", "stems"]).optional().describe("'combined' (default): one multi-track file. 'stems': one file per cell."),
6043
+ outputDir: z.string().optional().describe("Directory to write into (default: the Exports location from get_default_locations)."),
6044
+ },
6045
+ }, async ({ filename, cellIds, bars, mode, outputDir }) => {
6046
+ const check = await requireBridgeForCells(cellIds);
6047
+ if (check !== true)
6048
+ return check;
6049
+ const manifest = await loadManifestForProductGuard();
6050
+ if (manifest && isMfxManifest(manifest))
6051
+ return error("export_midi needs sampler cells with note patterns; MFX has none.");
6052
+ const [stateRaw, transportRaw, locations] = await Promise.all([
6053
+ bridge.send("getState"),
6054
+ bridge.send("getTransport").catch(() => undefined),
6055
+ bridge.send("getDefaultLocations").catch(() => undefined),
6056
+ ]);
6057
+ const state = isRecord(stateRaw) ? stateRaw : {};
6058
+ const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
6059
+ let dir = outputDir ? expandHome(outputDir) : undefined;
6060
+ if (!dir) {
6061
+ if (Array.isArray(locations)) {
6062
+ const exportsEntry = locations.find((entry) => isRecord(entry) && /export/i.test(String(entry.name)));
6063
+ if (isRecord(exportsEntry) && typeof exportsEntry.path === "string")
6064
+ dir = exportsEntry.path;
6065
+ }
6066
+ else if (isRecord(locations)) {
6067
+ dir = [locations.exports, locations.exportsLocation, locations.Exports].find((v) => typeof v === "string" && v.length > 0);
6068
+ }
6069
+ }
6070
+ if (!dir)
6071
+ return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
6072
+ const maxCell = manifestMaxCellId(manifest) ?? 8;
6073
+ const ids = (cellIds?.length ? cellIds : Array.from({ length: maxCell }, (_, i) => i + 1)).filter((id) => id <= maxCell);
6074
+ const cells = ids
6075
+ .map((id) => ({
6076
+ id,
6077
+ sequence: state[`cell${id}_midiSequence`],
6078
+ patternLength: state[`cell${id}_patternLength`],
6079
+ speed: state[`cell${id}_patternSpeed`],
6080
+ }))
6081
+ .filter((cell) => Array.isArray(cell.sequence) && cell.sequence.length > 0);
6082
+ if (cells.length === 0)
6083
+ return error("No note patterns to export — the selected cells carry no notes.");
6084
+ const cycleBars = cells.map((cell) => {
6085
+ const length = Number(cell.patternLength);
6086
+ const speedIndex = Math.round(Number(cell.speed));
6087
+ const mul = Number.isFinite(speedIndex) ? patternSpeedMultiplier(speedIndex) : 1;
6088
+ return (Number.isFinite(length) && length > 0 ? length : 4) / mul / 4;
6089
+ });
6090
+ const totalBars = bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars)));
6091
+ const totalBeats = totalBars * 4;
6092
+ const tracks = cells.map((cell) => ({
6093
+ name: `Cell ${cell.id}`,
6094
+ events: tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, Math.min(16, cell.id)),
6095
+ }));
6096
+ const base = (filename?.trim() || `pam_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
6097
+ await mkdir(dir, { recursive: true });
6098
+ const written = [];
6099
+ if (mode === "stems") {
6100
+ for (const track of tracks) {
6101
+ const path = join(dir, `${base}_cell${track.name.slice(5)}.mid`);
6102
+ await writeFile(path, buildMidiFile(bpm, [track], track.name));
6103
+ written.push(path);
6104
+ }
6105
+ }
6106
+ else {
6107
+ const path = join(dir, `${base}.mid`);
6108
+ await writeFile(path, buildMidiFile(bpm, tracks, base));
6109
+ written.push(path);
6110
+ }
6111
+ const noteCount = tracks.reduce((sum, track) => sum + track.events.length, 0);
6112
+ return text(`Exported ${noteCount} notes from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"} at ${bpm} BPM.\n` +
6113
+ written.map((path) => `File: ${path}`).join("\n"));
6114
+ });
5377
6115
  server.registerTool("remove_param_seq", {
5378
6116
  title: "Remove Parameter Sequencer",
5379
6117
  description: "Remove a parameter sequencer for the given paramId.",
@@ -5462,7 +6200,9 @@ server.registerTool("export_audio", {
5462
6200
  "'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
5463
6201
  "NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
5464
6202
  "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.",
6203
+ "(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those. " +
6204
+ "It issues the same render as `export_song` with strategy 'offline'; prefer `export_song` for arrangements, " +
6205
+ "which is the only route to the live and audio+video strategies.",
5466
6206
  inputSchema: {
5467
6207
  bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
5468
6208
  cellId: z
@@ -5480,9 +6220,9 @@ server.registerTool("export_audio", {
5480
6220
  .optional()
5481
6221
  .describe("For mode 'selected-cells': cells to bounce as stems."),
5482
6222
  bitDepth: z
5483
- .number()
6223
+ .union([z.literal(16), z.literal(24)])
5484
6224
  .default(24)
5485
- .describe("Bit depth (16 or 24)."),
6225
+ .describe("Bit depth: 16 or 24."),
5486
6226
  },
5487
6227
  }, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
5488
6228
  const check = await requireBridgeForCells(cellId, selectedCellIds);
@@ -6033,10 +6773,10 @@ if (!IS_MFX_SERVER)
6033
6773
  },
6034
6774
  }, ({ 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
6775
  `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` +
6776
+ `2. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}.\n` +
6777
+ `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
6778
  `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` +
6779
+ `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
6780
  `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
6041
6781
  if (!IS_MFX_SERVER)
6042
6782
  server.registerPrompt("make-a-song", {
@@ -6109,7 +6849,7 @@ async function applyLoopersDelta(loopers, extra) {
6109
6849
  server.registerTool("arm_looper_track", {
6110
6850
  title: "Arm / Disarm Looper Track",
6111
6851
  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).",
6852
+ "recording — call start_looper_recording afterwards, or pass armedCellIds to start_looper_recording to arm and record in one call.",
6113
6853
  inputSchema: {
6114
6854
  cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
6115
6855
  armed: z.boolean().describe("true to arm, false to disarm."),
@@ -6123,8 +6863,8 @@ server.registerTool("arm_looper_track", {
6123
6863
  });
6124
6864
  server.registerTool("set_looper_track", {
6125
6865
  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.",
6866
+ description: "Update one or more per-track looper fields (mute, solo, overdub mode, reverse, " +
6867
+ "pitch, warp, capture source, level, auto-select and recorded BPM). Pass only the fields you want to change.",
6128
6868
  inputSchema: {
6129
6869
  cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
6130
6870
  mute: z.boolean().optional().describe("Mute / unmute."),
@@ -6427,7 +7167,7 @@ server.registerTool("looper_variation", {
6427
7167
  data: z
6428
7168
  .record(z.string(), z.unknown())
6429
7169
  .optional()
6430
- .describe("For 'save': the looper variation snapshot (object). Pass null to clear instead."),
7170
+ .describe("For 'save': the looper variation snapshot (object). Omit this field to clear the slot instead."),
6431
7171
  },
6432
7172
  }, async (args) => {
6433
7173
  const check = await requireBridge();
@@ -6505,21 +7245,21 @@ server.registerTool("queue_variation", {
6505
7245
  inputSchema: {
6506
7246
  index: z.number().min(0).max(7).describe("Variation slot 0-7."),
6507
7247
  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."),
7248
+ 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
7249
  resetPhase: z
6510
7250
  .boolean()
6511
- .default(true)
6512
- .describe("Reset pattern phase on switch."),
7251
+ .optional()
7252
+ .describe("Reset pattern phase on switch. Omit to inherit native settings."),
6513
7253
  morphBars: z
6514
7254
  .number()
6515
7255
  .min(0.25)
6516
7256
  .max(8)
6517
7257
  .optional()
6518
- .describe("Pre-anchor morph window in bars for this flip (PAM >= 1.19.3). Omit = hard switch."),
7258
+ .describe("Pre-anchor morph window in bars for this flip. Omit all transition options to inherit native settings."),
6519
7259
  morphMode: z
6520
7260
  .enum(["auto", "param", "mix", "seq", "all"])
6521
7261
  .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)."),
7262
+ .describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise."),
6523
7263
  morphCurve: z
6524
7264
  .enum(["linear", "ease", "exp", "log"])
6525
7265
  .optional()
@@ -6532,7 +7272,7 @@ server.registerTool("queue_variation", {
6532
7272
  tailMaxBars: z.number().min(0.25).max(8).optional(),
6533
7273
  })
6534
7274
  .optional()
6535
- .describe("Advanced transition settings for this flip (PAM >= 1.20.0): pattern policy for a " +
7275
+ .describe("Advanced transition settings for this flip: pattern policy for a " +
6536
7276
  "seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
6537
7277
  "tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
6538
7278
  },
@@ -6543,8 +7283,8 @@ server.registerTool("queue_variation", {
6543
7283
  await bridge.send("queueVariationOnBars", {
6544
7284
  index,
6545
7285
  intervalBars,
6546
- fadeMs,
6547
- resetPhase,
7286
+ ...(fadeMs !== undefined && { fadeMs }),
7287
+ ...(resetPhase !== undefined && { resetPhase }),
6548
7288
  ...(morphBars !== undefined && { morphBars }),
6549
7289
  ...(morphMode !== undefined && { morphMode }),
6550
7290
  ...(morphCurve !== undefined && { morphCurve }),
@@ -6585,7 +7325,7 @@ server.registerTool("update_variations", {
6585
7325
  server.registerTool("variation_hold", {
6586
7326
  title: "Variation Hold",
6587
7327
  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.",
7328
+ "variation switches. Hold mode reverts when the note releases; latch mode stays.",
6589
7329
  inputSchema: {
6590
7330
  enabled: z.boolean().optional().describe("Toggle hold mode on/off."),
6591
7331
  allNotesOff: z
@@ -6707,7 +7447,6 @@ server.registerTool("update_modulation", {
6707
7447
  title: "Update Modulation",
6708
7448
  description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
6709
7449
  "or the Free Sequencer master pattern. " +
6710
- "The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
6711
7450
  "Use add_modulation to first attach a source to a target — then this tool to edit it.",
6712
7451
  inputSchema: {
6713
7452
  type: z
@@ -6715,7 +7454,11 @@ server.registerTool("update_modulation", {
6715
7454
  .describe("Which modulation system to update."),
6716
7455
  payload: z
6717
7456
  .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."),
7457
+ .describe("Update payload object, one property per call. lfo, envelope, vary, macro and paramSeq all take " +
7458
+ "{ index, property, value }, where index is the 0-based position in that state array (get_state " +
7459
+ "section 'lfos' / 'envelopes' / 'vary' / 'macros' / 'paramSeqs') and property is the field name to " +
7460
+ "overwrite. paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes " +
7461
+ "{ sequence: number[] } — the master step contour every free-mode ParamSeq follows."),
6719
7462
  },
6720
7463
  }, async ({ type, payload }) => {
6721
7464
  const check = await requireBridge();
@@ -6741,13 +7484,6 @@ server.registerTool("update_modulation", {
6741
7484
  // ============================================================
6742
7485
  // TOOLS — Song mode
6743
7486
  // ============================================================
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
7487
  const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
6752
7488
  const transitionSpecSchema = z.object({
6753
7489
  mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
@@ -6757,6 +7493,10 @@ const transitionSpecSchema = z.object({
6757
7493
  paramCurve: morphCurveEnum.optional(),
6758
7494
  patternCurve: morphCurveEnum.optional(),
6759
7495
  mixCurve: morphCurveEnum.optional(),
7496
+ staggerBy: z.enum(["off", "cell", "category"]).optional(),
7497
+ staggerSpread: z.number().min(0).max(0.9).optional(),
7498
+ staggerOrder: z.enum(["forward", "reverse"]).optional(),
7499
+ relations: z.enum(["off", "stepped"]).optional(),
6760
7500
  })
6761
7501
  .optional(),
6762
7502
  pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
@@ -6769,23 +7509,25 @@ const transitionSpecSchema = z.object({
6769
7509
  .optional(),
6770
7510
  phase: z.enum(["inherit", "reset", "continue"]).optional(),
6771
7511
  });
6772
- const TRANSITION_SPEC_HONESTY = "Advanced transition spec (PAM >= 1.19.6; live engine from PAM >= 1.20.0): pattern.policy " +
7512
+ const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
6773
7513
  "('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
6774
7514
  "whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
6775
7515
  "correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
6776
7516
  "lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
6777
7517
  "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 " +
7518
+ "defaults linear). window.staggerBy (off/cell/category), staggerSpread (0..0.9), staggerOrder (forward/reverse), and relations (off/stepped) shape the parameter glide. Runs on variation sections + queued flips, and on preset destinations " +
7519
+ "when the lanePresetSections setting is on (the dual-lane preset section " +
6780
7520
  "pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
6781
7521
  "coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
6782
7522
  "(effective values after inheritance and capability coercion) and lastMorphResolution " +
6783
- "(the engine's latest AUTO decision + reason).";
7523
+ "(the engine's latest AUTO decision + reason; published only for an AUTO request, and " +
7524
+ "one per song rather than one per cable).";
6784
7525
  server.registerTool("song_mode", {
6785
7526
  title: "Song Mode",
6786
7527
  description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
6787
7528
  "'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
6788
- "'setCrossfadeMs', 'setPlayhead'.\n\n" +
7529
+ "'setCrossfadeMs', 'setPlayhead'. WebView-routed (window must be OPEN): setArrangement, get, assignVx. " +
7530
+ "Headless: setSequence, clear, toggle, setLoopPoints, toggleLoop, setCrossfadeMs, setPlayhead.\n\n" +
6789
7531
  "PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
6790
7532
  "from a `sections` list, with an optional weighted `connections` graph. The result is " +
6791
7533
  "visible/editable in the UI, and each section can pin a " +
@@ -6793,8 +7535,11 @@ server.registerTool("song_mode", {
6793
7535
  "audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
6794
7536
  "`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
6795
7537
  "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). " +
7538
+ "incoming variation's continuous params over the bars before its boundary, and an " +
7539
+ "advanced `transition` spec (see the field description). " +
7540
+ "A `transitionPreset` id (or `defaultTransitionPreset` at the top level) stamps a whole named " +
7541
+ "transition in one field; op 'get' lists the available ids in `transitionPresets` and reports " +
7542
+ "each cable's derived `transitionPreset`. " +
6798
7543
  "'get' reads the arrangement back (nodes, compiled steps with the " +
6799
7544
  "VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
6800
7545
  "transition after inheritance and capability coercion, e.g. preset destinations report param); " +
@@ -6824,14 +7569,14 @@ server.registerTool("song_mode", {
6824
7569
  repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
6825
7570
  muted: z.boolean().optional().describe("Pass through without an audio trigger."),
6826
7571
  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."),
7572
+ 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
7573
  label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
6829
7574
  morphBars: z
6830
7575
  .number()
6831
7576
  .min(0)
6832
7577
  .max(8)
6833
7578
  .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."),
7579
+ .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
7580
  morphCurve: z
6836
7581
  .enum(["linear", "ease", "exp", "log"])
6837
7582
  .optional()
@@ -6839,8 +7584,12 @@ server.registerTool("song_mode", {
6839
7584
  morphMode: z
6840
7585
  .enum(["auto", "param", "mix", "seq", "all"])
6841
7586
  .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."),
7587
+ .describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise. 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend. Ignored without morphBars."),
6843
7588
  transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
7589
+ transitionPreset: z
7590
+ .string()
7591
+ .optional()
7592
+ .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
7593
  vxVariationIndex: z
6845
7594
  .number()
6846
7595
  .int()
@@ -6883,6 +7632,10 @@ server.registerTool("song_mode", {
6883
7632
  .optional()
6884
7633
  .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
7634
  transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
7635
+ transitionPreset: z
7636
+ .string()
7637
+ .optional()
7638
+ .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
7639
  }))
6887
7640
  .optional()
6888
7641
  .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 +7643,11 @@ server.registerTool("song_mode", {
6890
7643
  .nullable()
6891
7644
  .optional()
6892
7645
  .describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
7646
+ defaultTransitionPreset: z
7647
+ .string()
7648
+ .nullable()
7649
+ .optional()
7650
+ .describe("For 'setArrangement': stamp a named preset as the song-level default transition (see transitionPreset)."),
6893
7651
  loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
6894
7652
  nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
6895
7653
  stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
@@ -6948,29 +7706,37 @@ server.registerTool("song_mode", {
6948
7706
  case "setArrangement": {
6949
7707
  if (!args.sections || args.sections.length === 0)
6950
7708
  return error("'setArrangement' requires sections");
7709
+ const requestId = mintVideoRequestId();
6951
7710
  await sendVideoCommand({
6952
7711
  op: "setSongArrangement",
7712
+ requestId,
6953
7713
  sections: args.sections,
6954
7714
  ...(args.connections !== undefined && { connections: args.connections }),
6955
7715
  ...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
7716
+ ...(args.defaultTransitionPreset !== undefined && { defaultTransitionPreset: args.defaultTransitionPreset }),
6956
7717
  ...(args.loop !== undefined && { loop: args.loop }),
6957
7718
  ...(args.enabled !== undefined && { enabled: args.enabled }),
6958
7719
  ...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
6959
7720
  });
6960
- // The forward path is fire-and-forget — poll until the WebView's apply
6961
- // republishes the snapshot (same pattern as resolveNewId).
6962
7721
  const deadline = Date.now() + 3000;
6963
7722
  let sm;
7723
+ let acknowledged = false;
6964
7724
  for (;;) {
6965
7725
  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)
7726
+ const ack = state.lastCommandAck;
7727
+ if (ack?.requestId === requestId) {
7728
+ if (ack.ok !== true)
7729
+ return error(ack.error ?? "Song arrangement was rejected.");
7730
+ sm = state.songMode;
7731
+ acknowledged = true;
7732
+ break;
7733
+ }
7734
+ if (Date.now() > deadline)
6971
7735
  break;
6972
7736
  await sleep(150);
6973
7737
  }
7738
+ if (!acknowledged)
7739
+ 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
7740
  if (!sm || sm.available !== true)
6975
7741
  return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
6976
7742
  "with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
@@ -7055,11 +7821,13 @@ server.registerTool("export_song", {
7055
7821
  "• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
7056
7822
  "closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
7057
7823
  "• '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. " +
7824
+ "variation section applies sample-accurately on its anchor and renders faster than realtime; `bars` is still required and capped at 64. " +
7059
7825
  "Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
7060
7826
  "recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
7061
7827
  "and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
7062
7828
  "Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
7829
+ "This is the entry point for arrangement bounces: strategy 'offline' issues the same render as " +
7830
+ "`export_audio` with mode 'song-mode', and the live and audio+video strategies exist only here. " +
7063
7831
  "Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
7064
7832
  "from get_default_locations).",
7065
7833
  inputSchema: {
@@ -7068,7 +7836,7 @@ server.registerTool("export_song", {
7068
7836
  .number()
7069
7837
  .min(1)
7070
7838
  .max(64)
7071
- .describe("Total arrangement length in bars (the full timeline). Required for 'live'."),
7839
+ .describe("Total arrangement length in bars (the full timeline), 1-64. Required for every strategy."),
7072
7840
  cellId: z
7073
7841
  .number()
7074
7842
  .min(1)
@@ -7082,7 +7850,10 @@ server.registerTool("export_song", {
7082
7850
  name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
7083
7851
  normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
7084
7852
  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)."),
7853
+ bitDepth: z
7854
+ .union([z.literal(16), z.literal(24)])
7855
+ .default(24)
7856
+ .describe("Bit depth: 16 or 24 (legacy cell-capture + offline paths)."),
7086
7857
  tailBars: z
7087
7858
  .number()
7088
7859
  .min(0)
@@ -7235,9 +8006,10 @@ server.registerTool("set_metronome", {
7235
8006
  // ============================================================
7236
8007
  server.registerTool("analyze_sample_tempo", {
7237
8008
  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.",
8009
+ description: "Run BPM detection and transient/slice analysis on an audio file. The result lands in the plugin's " +
8010
+ "sample-metadata cache and the editor's sample library; the bridge does not capture it, so there is no " +
8011
+ "get_state key that reads it back. Run it before resample_sample_to_bpm so the stretch has a source BPM, " +
8012
+ "and use get_audio_info for a file's own format properties.",
7241
8013
  inputSchema: {
7242
8014
  filePath: z.string().describe("Path to the audio file."),
7243
8015
  transientSensitivity: z
@@ -7296,8 +8068,8 @@ server.registerTool("preview_slice", {
7296
8068
  title: "Preview Slice",
7297
8069
  description: "Audition a single slice of a cell's sample.",
7298
8070
  inputSchema: {
7299
- cellId: z.number().min(1).max(8),
7300
- sliceNumber: z.number().min(0).describe("Slice index."),
8071
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
8072
+ sliceNumber: z.number().min(0).describe("Slice index (0-based)."),
7301
8073
  },
7302
8074
  }, async ({ cellId, sliceNumber }) => {
7303
8075
  const check = await requireBridgeForCells(cellId);
@@ -7311,7 +8083,7 @@ server.registerTool("resample_sample_to_bpm", {
7311
8083
  description: "Time-stretch a cell's sample to the current project BPM (or a target BPM). " +
7312
8084
  "Non-destructive — use restore_original_sample to revert.",
7313
8085
  inputSchema: {
7314
- cellId: z.number().min(1).max(8),
8086
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
7315
8087
  sourceBpm: z
7316
8088
  .number()
7317
8089
  .min(20)
@@ -7336,7 +8108,7 @@ server.registerTool("restore_original_sample", {
7336
8108
  title: "Restore Original Sample",
7337
8109
  description: "Revert a cell's sample to its un-edited source (undoes resample / trim / etc.).",
7338
8110
  inputSchema: {
7339
- cellId: z.number().min(1).max(8),
8111
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
7340
8112
  },
7341
8113
  }, async ({ cellId }) => {
7342
8114
  const check = await requireBridgeForCells(cellId);
@@ -7363,8 +8135,8 @@ server.registerTool("export_slice", {
7363
8135
  title: "Export Slice",
7364
8136
  description: "Export a single slice of a cell's sample as a standalone WAV.",
7365
8137
  inputSchema: {
7366
- cellId: z.number().min(1).max(8),
7367
- sliceIndex: z.number().min(0),
8138
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
8139
+ sliceIndex: z.number().min(0).describe("Slice index (0-based)."),
7368
8140
  },
7369
8141
  }, async ({ cellId, sliceIndex }) => {
7370
8142
  const check = await requireBridgeForCells(cellId);
@@ -7381,7 +8153,8 @@ server.registerTool("live_resample", {
7381
8153
  description: "Capture PAM's audio graph output into a cell as a new sample, in real time. Ops: 'start' / 'stop'. " +
7382
8154
  "Transport MUST be playing for the capture to produce audio. Because the capture runs live, " +
7383
8155
  "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" +
8156
+ "this is the building block for full-track / Song Mode bounces (see export_song). On finalize the captured clip " +
8157
+ "REPLACES whatever sample the destination cell held — use an empty cell, or reload the preset before saving anything afterwards.\n\n" +
7385
8158
  "`source` selects what to record: 'cell' (the destination cell's own output), 'input' (PAM's audio input), " +
7386
8159
  "or 'fullmix' (the whole master mix — use this to bounce the entire arrangement).",
7387
8160
  inputSchema: {
@@ -7660,7 +8433,7 @@ server.registerTool("list_loaded_plugins", {
7660
8433
  title: "List Loaded Plugins",
7661
8434
  description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
7662
8435
  "Each entry: cellId, name, manufacturer, format, " +
7663
- "path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue). " +
8436
+ "path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param). " +
7664
8437
  "Use the param `index` with `set_plugin_param`.",
7665
8438
  inputSchema: {},
7666
8439
  }, async () => {
@@ -7702,7 +8475,8 @@ server.registerTool("load_plugin", {
7702
8475
  ...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
7703
8476
  ...(args.name ? { name: args.name } : {}),
7704
8477
  }));
7705
- return text(raw);
8478
+ // Native answers a success with an object (`{ok: true, …}`), a failure with a string.
8479
+ return text(typeof raw === "string" ? raw : JSON.stringify(raw));
7706
8480
  });
7707
8481
  server.registerTool("unload_plugin", {
7708
8482
  title: "Unload Plugin From Cell",
@@ -7725,10 +8499,10 @@ server.registerTool("set_plugin_param", {
7725
8499
  description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
7726
8500
  "Use `list_loaded_plugins` to find param indices and names. " +
7727
8501
  "This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
7728
- "Targets the cell's instrument-slot plugin only.",
8502
+ "It exists for a single instrument-slot value; use `set_plugin_params` for a batch or for the FX slot.",
7729
8503
  inputSchema: {
7730
- cellId: z.number().int().min(1),
7731
- paramIndex: z.number().int().min(0),
8504
+ cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
8505
+ paramIndex: z.number().int().min(0).describe("Parameter index from list_loaded_plugins."),
7732
8506
  value: z.number().min(0).max(1).describe("Normalized 0..1."),
7733
8507
  },
7734
8508
  }, async (args) => {
@@ -7750,8 +8524,8 @@ server.registerTool("set_plugin_bypass", {
7750
8524
  description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
7751
8525
  "still plays in parallel.",
7752
8526
  inputSchema: {
7753
- cellId: z.number().int().min(1),
7754
- bypassed: z.boolean(),
8527
+ cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
8528
+ bypassed: z.boolean().describe("true silences the hosted plugin, false re-enables it."),
7755
8529
  },
7756
8530
  }, async (args) => {
7757
8531
  const check = await requireBridge();
@@ -7972,7 +8746,9 @@ server.registerTool("get_sample_usage", {
7972
8746
  description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
7973
8747
  "mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
7974
8748
  inputSchema: {
7975
- mode: z.enum(["presetUsesSamples", "presetsUsingSample"]),
8749
+ mode: z
8750
+ .enum(["presetUsesSamples", "presetsUsingSample"])
8751
+ .describe("Which direction to cross-reference: from a preset to its samples, or from a sample to the presets using it."),
7976
8752
  path: z
7977
8753
  .string()
7978
8754
  .describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
@@ -7997,7 +8773,9 @@ server.registerTool("repair_preset_samples", {
7997
8773
  "library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
7998
8774
  "reports them. Useful after moving a sample library or importing presets from another machine.",
7999
8775
  inputSchema: {
8000
- mode: z.enum(["fix", "rescan"]),
8776
+ mode: z
8777
+ .enum(["fix", "rescan"])
8778
+ .describe("'fix' relinks moved samples by searching the library; 'rescan' only reports what is still missing."),
8001
8779
  presetPath: z
8002
8780
  .string()
8003
8781
  .optional()
@@ -8041,7 +8819,9 @@ server.registerTool("preview_sample", {
8041
8819
  server.registerTool("set_plugin_params", {
8042
8820
  title: "Set Hosted Plugin Params (batch)",
8043
8821
  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'.",
8822
+ "0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'. " +
8823
+ "role:'effect' reaches the FX slot, but list_loaded_plugins reports instrument-slot plugins only, so " +
8824
+ "effect-slot param indices are not discoverable through MCP.",
8045
8825
  inputSchema: {
8046
8826
  cellId: z.number().int().min(1).describe("Cell number."),
8047
8827
  role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
@@ -8291,9 +9071,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
8291
9071
  playing: Boolean(transport.playing ?? transport.isPlaying),
8292
9072
  preset,
8293
9073
  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)` })),
9074
+ muted: (overview.cells ?? []).filter((c) => c.muted).map((c) => c.cellId),
9075
+ cells: (overview.cells ?? []).flatMap((c) => {
9076
+ const name = c.sample?.path || c.sample?.name
9077
+ || (c.synthEngine && c.synthEngine.id > 0 ? `${c.synthEngine.name}(synth)` : undefined);
9078
+ return name ? [{ id: c.cellId, name }] : [];
9079
+ }),
8297
9080
  };
8298
9081
  }
8299
9082
  }