@map-audio/pam-mcp-server 1.0.0 → 1.1.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
@@ -3,11 +3,18 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { readFile } from "node:fs/promises";
6
- import { basename } from "node:path";
6
+ import { basename, dirname, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
8
9
  import { PluginBridge } from "./bridge.js";
9
10
  // --- State ---
11
+ // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
12
+ // interface/src/stores/Preset.ts. Defined locally because the MCP package
13
+ // can't import from the interface workspace without infra changes.
14
+ const PRESET_VARIATION_SLOTS = 8;
10
15
  const bridge = new PluginBridge();
16
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const cellModeChangeSeq = {};
11
18
  // --- Helpers ---
12
19
  function text(s) {
13
20
  return { content: [{ type: "text", text: s }] };
@@ -18,6 +25,31 @@ function json(obj) {
18
25
  function error(msg) {
19
26
  return { content: [{ type: "text", text: msg }], isError: true };
20
27
  }
28
+ async function readPackageVersion() {
29
+ try {
30
+ const raw = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
31
+ const pkg = JSON.parse(raw);
32
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
33
+ }
34
+ catch {
35
+ return "unknown";
36
+ }
37
+ }
38
+ async function handleCliArgs() {
39
+ const args = new Set(process.argv.slice(2));
40
+ if (args.has("--version") || args.has("-v")) {
41
+ console.log(await readPackageVersion());
42
+ return true;
43
+ }
44
+ if (args.has("--help") || args.has("-h")) {
45
+ const version = await readPackageVersion();
46
+ console.log(`pam-mcp ${version}\n\n` +
47
+ "MCP stdio server for PAM. Configure your MCP client to launch this command; " +
48
+ "it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
49
+ return true;
50
+ }
51
+ return false;
52
+ }
21
53
  async function requireBridge() {
22
54
  if (bridge.isConnected())
23
55
  return true;
@@ -53,14 +85,14 @@ async function readExistingVariations(presetPath) {
53
85
  const fullPath = await resolvePresetPath(presetPath);
54
86
  const raw = await readFile(fullPath, "utf-8");
55
87
  const { metadata } = parsePresetFile(raw);
56
- if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length === 8) {
88
+ if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length === PRESET_VARIATION_SLOTS) {
57
89
  return [...metadata.variations];
58
90
  }
59
91
  }
60
92
  catch {
61
93
  // Preset doesn't exist yet or can't be read — start fresh
62
94
  }
63
- return Array(8).fill(null);
95
+ return Array(PRESET_VARIATION_SLOTS).fill(null);
64
96
  }
65
97
  // Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
66
98
  const VARIATION_EXCLUDED_PARAMS = [
@@ -68,6 +100,11 @@ const VARIATION_EXCLUDED_PARAMS = [
68
100
  "interfaceCache",
69
101
  "songMode",
70
102
  "volume",
103
+ "hqMode",
104
+ "previewSamplePath",
105
+ "previewCellId",
106
+ "transport",
107
+ "snapshotRateHz",
71
108
  "looperCrossfade",
72
109
  "looperLength",
73
110
  "looperVolume",
@@ -115,13 +152,19 @@ function formatParam(p) {
115
152
  // --- Server ---
116
153
  const server = new McpServer({
117
154
  name: "pam",
118
- version: "1.0.0",
155
+ version: "1.1.0",
119
156
  }, {
120
157
  instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
121
158
  "first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
122
159
  "The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
123
160
  "Use the returned paths with list_samples. When loading samples, use load_sample with " +
124
- "paths from list_samples. Transport must be playing for sequencer output.",
161
+ "paths from list_samples. Transport must be playing for sequencer output. " +
162
+ "Tools added in this server version (1.1.0) — looper recording/variations, song mode, " +
163
+ "modulation edits (LFO/Env/Vary/Macro/ParamSeq), MIDI CC/PC, MIDI recording, sample " +
164
+ "analysis, live resample, bar-synced variation scheduling, variation hold — require " +
165
+ "PAM ≥ 1.4.44. If a tool fails with \"PAM build does not support 'X'\", the connected " +
166
+ "plugin is older than this MCP server; the user must update PAM (updating the npm " +
167
+ "package alone is not enough).",
125
168
  });
126
169
  // ============================================================
127
170
  // TOOLS — Offline (work without plugin running)
@@ -345,7 +388,7 @@ server.registerTool("get_state", {
345
388
  "Returns all parameter values, cell configs, modulation, and sequences.",
346
389
  inputSchema: {
347
390
  section: z
348
- .enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs"])
391
+ .enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
349
392
  .optional()
350
393
  .describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
351
394
  },
@@ -813,7 +856,14 @@ server.registerTool("save_preset", {
813
856
  const state = (await bridge.send("getState"));
814
857
  const variationString = buildVariationString(state);
815
858
  const presetRelPath = path || `User/${name}.preset`;
816
- const existingVariations = await readExistingVariations(presetRelPath);
859
+ // Strip "Presets/" prefix if present — resolvePresetPath already prepends
860
+ // the presets base directory, so "Presets/User/X" would double up to
861
+ // ".../Presets/Presets/User/X" and silently fail to read back variations,
862
+ // wiping slots 1-7 on save. Mirrors save_variation's handling.
863
+ const readPath = presetRelPath.startsWith("Presets/")
864
+ ? presetRelPath.slice("Presets/".length)
865
+ : presetRelPath;
866
+ const existingVariations = await readExistingVariations(readPath);
817
867
  const hasVariations = existingVariations.some((v) => v !== null);
818
868
  if (!hasVariations) {
819
869
  existingVariations[0] = variationString;
@@ -885,13 +935,13 @@ server.registerTool("save_variation", {
885
935
  }
886
936
  // 3. Read existing preset metadata and variations from disk
887
937
  let existingMeta = {};
888
- let variations = Array(8).fill(null);
938
+ let variations = Array(PRESET_VARIATION_SLOTS).fill(null);
889
939
  try {
890
940
  const fullPath = await resolvePresetPath(diskPath);
891
941
  const raw = await readFile(fullPath, "utf-8");
892
942
  const { metadata: m } = parsePresetFile(raw);
893
943
  existingMeta = m;
894
- if (m.variations && Array.isArray(m.variations) && m.variations.length === 8) {
944
+ if (m.variations && Array.isArray(m.variations) && m.variations.length === PRESET_VARIATION_SLOTS) {
895
945
  variations = [...m.variations];
896
946
  }
897
947
  }
@@ -1533,6 +1583,14 @@ server.registerTool("update_cell_config", {
1533
1583
  polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation."),
1534
1584
  scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
1535
1585
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
1586
+ pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
1587
+ stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
1588
+ 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."),
1589
+ 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."),
1590
+ grainDensity: z.number().int().min(1).max(24).optional().describe("Cloud stretch grain density when stretchMode=1."),
1591
+ 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."),
1592
+ spread: z.number().min(0).max(100).optional().describe("Cloud stretch grain spread (0..100) when stretchMode=1."),
1593
+ chaos: z.number().min(0).max(100).optional().describe("Cloud stretch grain chaos (0..100) when stretchMode=1."),
1536
1594
  sidechainFrom: z
1537
1595
  .union([z.number().min(0).max(8), z.null()])
1538
1596
  .optional()
@@ -1556,6 +1614,11 @@ server.registerTool("update_cell_config", {
1556
1614
  if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
1557
1615
  flatDelta[`cell${cellId}_compressorEnabled`] = 1;
1558
1616
  }
1617
+ if (cellConfig.pitchMode !== undefined || cellConfig.stretchMode !== undefined) {
1618
+ const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
1619
+ cellModeChangeSeq[cellId] = next;
1620
+ cellConfig.modeChangeSeq = next;
1621
+ }
1559
1622
  const delta = { cells: { [cellId]: cellConfig }, ...flatDelta };
1560
1623
  await applyKv(delta, KV_OPTS_CELL_CONFIG);
1561
1624
  const summary = Object.entries(cellConfig)
@@ -2159,9 +2222,910 @@ Index → multiplier: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3,
2159
2222
  ],
2160
2223
  }));
2161
2224
  // ============================================================
2225
+ // TOOLS — Looper (state deltas)
2226
+ // ============================================================
2227
+ // applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
2228
+ const KV_OPTS_LOOPER_STATE = {
2229
+ shouldDispatch: true,
2230
+ syncKVResources: true,
2231
+ syncConfigResources: true,
2232
+ resyncKVAfterDispatch: true,
2233
+ updateParameters: true,
2234
+ broadcastFollowers: true,
2235
+ };
2236
+ async function applyLoopersDelta(loopers, extra) {
2237
+ const delta = { loopers, ...(extra ?? {}) };
2238
+ await bridge.send("applyUnifiedStateKvUpdate", {
2239
+ delta,
2240
+ options: KV_OPTS_LOOPER_STATE,
2241
+ });
2242
+ }
2243
+ server.registerTool("arm_looper_track", {
2244
+ title: "Arm / Disarm Looper Track",
2245
+ description: "Arm or disarm a looper track for recording. Arming alone does not start " +
2246
+ "recording — call start_looper_recording afterwards (or use arm_and_record).",
2247
+ inputSchema: {
2248
+ cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
2249
+ armed: z.boolean().describe("true to arm, false to disarm."),
2250
+ },
2251
+ }, async ({ cellId, armed }) => {
2252
+ const check = await requireBridge();
2253
+ if (check !== true)
2254
+ return check;
2255
+ await applyLoopersDelta({ [cellId]: { armed: armed ? 1 : 0 } });
2256
+ return text(`${armed ? "Armed" : "Disarmed"} looper track ${cellId}.`);
2257
+ });
2258
+ server.registerTool("set_looper_track", {
2259
+ title: "Set Looper Track State",
2260
+ description: "Update one or more per-track looper fields (mute, overdub mode, reverse, " +
2261
+ "pitch, warp, capture source, level). Pass only the fields you want to change.",
2262
+ inputSchema: {
2263
+ cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
2264
+ mute: z.boolean().optional().describe("Mute / unmute."),
2265
+ solo: z.boolean().optional().describe("Solo / unsolo (toggle others off if true)."),
2266
+ overdubMode: z
2267
+ .enum(["replace", "overdub"])
2268
+ .optional()
2269
+ .describe("Overdub vs replace behavior on next recording."),
2270
+ reverse: z.boolean().optional().describe("Play recorded clip in reverse."),
2271
+ pitchSemitones: z
2272
+ .number()
2273
+ .min(-24)
2274
+ .max(24)
2275
+ .optional()
2276
+ .describe("Pitch shift in semitones (-24..+24)."),
2277
+ pitchMode: z
2278
+ .enum(["varispeed", "pitchshift"])
2279
+ .optional()
2280
+ .describe("'varispeed' (rate-coupled tape pitch) or 'pitchshift' (formant-preserving)."),
2281
+ warp: z.boolean().optional().describe("Enable tempo-warped playback."),
2282
+ warpMode: z
2283
+ .enum(["offline", "realtime"])
2284
+ .optional()
2285
+ .describe("Offline (pre-rendered) or realtime (Signalsmith) warp."),
2286
+ level: z
2287
+ .number()
2288
+ .min(0)
2289
+ .max(2)
2290
+ .optional()
2291
+ .describe("Per-track gain multiplier (0..2)."),
2292
+ sourceCell: z
2293
+ .number()
2294
+ .min(0)
2295
+ .max(8)
2296
+ .optional()
2297
+ .describe("Capture source: 0 = external input, 1-8 = cell output. (UI-side inverted vs captureSource.)"),
2298
+ autoSelect: z
2299
+ .boolean()
2300
+ .optional()
2301
+ .describe("Enable auto-select cycling of clip regions."),
2302
+ recordedBpm: z
2303
+ .number()
2304
+ .min(0)
2305
+ .max(999)
2306
+ .optional()
2307
+ .describe("Override the recorded BPM for the track (0 = unset)."),
2308
+ },
2309
+ }, async (args) => {
2310
+ const check = await requireBridge();
2311
+ if (check !== true)
2312
+ return check;
2313
+ const { cellId, mute, solo, ...rest } = args;
2314
+ const trackDelta = {};
2315
+ if (typeof mute === "boolean")
2316
+ trackDelta.mute = mute ? 1 : 0;
2317
+ if (typeof solo === "boolean")
2318
+ trackDelta.solo = solo ? 1 : 0;
2319
+ if (rest.overdubMode !== undefined)
2320
+ trackDelta.overdubMode = rest.overdubMode;
2321
+ if (typeof rest.reverse === "boolean")
2322
+ trackDelta.reverse = rest.reverse ? 1 : 0;
2323
+ if (typeof rest.pitchSemitones === "number")
2324
+ trackDelta.pitchSemitones = rest.pitchSemitones;
2325
+ if (rest.pitchMode !== undefined)
2326
+ trackDelta.pitchMode = rest.pitchMode;
2327
+ if (typeof rest.warp === "boolean")
2328
+ trackDelta.warpEnabled = rest.warp ? 1 : 0;
2329
+ if (rest.warpMode !== undefined)
2330
+ trackDelta.warpMode = rest.warpMode;
2331
+ if (typeof rest.level === "number")
2332
+ trackDelta.level = rest.level;
2333
+ if (typeof rest.sourceCell === "number")
2334
+ trackDelta.sourceCell = rest.sourceCell;
2335
+ if (typeof rest.autoSelect === "boolean")
2336
+ trackDelta.autoSelect = rest.autoSelect ? 1 : 0;
2337
+ if (typeof rest.recordedBpm === "number")
2338
+ trackDelta.recordedBpm = rest.recordedBpm;
2339
+ if (Object.keys(trackDelta).length === 0)
2340
+ return text(`No fields to update for looper track ${cellId}.`);
2341
+ await applyLoopersDelta({ [cellId]: trackDelta });
2342
+ return text(`Updated looper track ${cellId}: ${JSON.stringify(trackDelta)}`);
2343
+ });
2344
+ server.registerTool("clear_looper_track", {
2345
+ title: "Clear Looper Track",
2346
+ description: "Clear the recorded clip and any selection state for a looper track. " +
2347
+ "Does NOT delete the audio file from disk (the C++ side handles file cleanup separately).",
2348
+ inputSchema: {
2349
+ cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
2350
+ },
2351
+ }, async ({ cellId }) => {
2352
+ const check = await requireBridge();
2353
+ if (check !== true)
2354
+ return check;
2355
+ await applyLoopersDelta({
2356
+ [cellId]: {
2357
+ clipPath: "",
2358
+ clipPaths: [],
2359
+ lastRecordedBars: 0,
2360
+ startPPQ: 0,
2361
+ warpedClipPath: "",
2362
+ recordedBpm: 0,
2363
+ selectionStart: 0,
2364
+ selectionEnd: 1,
2365
+ },
2366
+ });
2367
+ return text(`Cleared looper track ${cellId} clip + selection.`);
2368
+ });
2369
+ server.registerTool("set_looper_track_selection", {
2370
+ title: "Set Looper Track Selection",
2371
+ description: "Set the in/out selection (in loop-normalized 0..1 coordinates) for a looper track.",
2372
+ inputSchema: {
2373
+ cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
2374
+ start: z.number().min(0).max(1).describe("Start (0..1)."),
2375
+ end: z.number().min(0).max(1).describe("End (0..1, must be > start)."),
2376
+ },
2377
+ }, async ({ cellId, start, end }) => {
2378
+ if (end <= start)
2379
+ return error("end must be > start");
2380
+ const check = await requireBridge();
2381
+ if (check !== true)
2382
+ return check;
2383
+ await applyLoopersDelta({
2384
+ [cellId]: { selectionStart: start, selectionEnd: end },
2385
+ });
2386
+ return text(`Looper track ${cellId} selection set to [${start}..${end}].`);
2387
+ });
2388
+ // ============================================================
2389
+ // TOOLS — Looper (native commands)
2390
+ // ============================================================
2391
+ server.registerTool("start_looper_recording", {
2392
+ title: "Start Looper Recording",
2393
+ description: "Queue a looper recording. Recording starts at the next bar boundary when the transport is playing, " +
2394
+ "or immediately when stopped. Tracks must be armed first (or pass armedCellIds to arm + record). " +
2395
+ "Each track's captureSource: 0 = cell output (post-FX), 1 = external input.",
2396
+ inputSchema: {
2397
+ loopBars: z.number().min(0.125).max(64).describe("Loop length in bars."),
2398
+ armedCellIds: z
2399
+ .array(z.number().min(1).max(8))
2400
+ .min(1)
2401
+ .describe("Cells to record (1-8)."),
2402
+ trackMetadata: z
2403
+ .array(z.object({
2404
+ cellId: z.number().min(1).max(8),
2405
+ overdubMode: z.enum(["replace", "overdub"]).default("replace"),
2406
+ existingClipPaths: z.array(z.string()).optional(),
2407
+ existingStartPPQ: z.number().default(0),
2408
+ captureSource: z.number().min(0).max(1).default(0),
2409
+ }))
2410
+ .optional()
2411
+ .describe("Per-track metadata. Defaults: replace mode, cell-output source. Pass existingClipPaths to enable overdub on a prior layer."),
2412
+ },
2413
+ }, async ({ loopBars, armedCellIds, trackMetadata }) => {
2414
+ const check = await requireBridge();
2415
+ if (check !== true)
2416
+ return check;
2417
+ const meta = trackMetadata ??
2418
+ armedCellIds.map((cellId) => ({
2419
+ cellId,
2420
+ overdubMode: "replace",
2421
+ existingClipPaths: [],
2422
+ existingStartPPQ: 0,
2423
+ captureSource: 0,
2424
+ }));
2425
+ await bridge.send("startLooperRecord", {
2426
+ loopBars,
2427
+ armedCellIds,
2428
+ trackMetadata: meta,
2429
+ });
2430
+ return text(`Queued looper recording: ${loopBars} bar${loopBars === 1 ? "" : "s"} on cells ${armedCellIds.join(", ")}.`);
2431
+ });
2432
+ server.registerTool("stop_looper_recording", {
2433
+ title: "Stop Looper Recording",
2434
+ description: "Stop or cancel the active looper recording session.",
2435
+ inputSchema: {},
2436
+ }, async () => {
2437
+ const check = await requireBridge();
2438
+ if (check !== true)
2439
+ return check;
2440
+ await bridge.send("stopLooperRecord");
2441
+ return text("Stopped looper recording.");
2442
+ });
2443
+ server.registerTool("capture_looper_now", {
2444
+ title: "Capture Looper Now",
2445
+ description: "Capture audio that is already in the rolling buffer (requires looper_always_listen=true). " +
2446
+ "Fires immediately if the buffer holds enough audio for the requested bars; otherwise arms and waits.",
2447
+ inputSchema: {
2448
+ bars: z.number().min(0.125).max(64).describe("Bars to capture."),
2449
+ mode: z
2450
+ .enum(["match", "overdub", "replace"])
2451
+ .default("replace")
2452
+ .describe("'match' = follow each track's overdubMode, 'overdub' = layer on existing, 'replace' = overwrite."),
2453
+ },
2454
+ }, async ({ bars, mode }) => {
2455
+ const check = await requireBridge();
2456
+ if (check !== true)
2457
+ return check;
2458
+ await bridge.send("captureLooperNow", { bars, mode });
2459
+ return text(`Captured looper: ${bars} bar${bars === 1 ? "" : "s"} (${mode}).`);
2460
+ });
2461
+ server.registerTool("load_audio_to_looper_track", {
2462
+ title: "Load Audio To Looper Track",
2463
+ description: "Load an audio file directly into a looper track (skips recording). " +
2464
+ "File must be a wav/mp3/aif/flac under the PAM base path or absolute.",
2465
+ inputSchema: {
2466
+ filePath: z.string().describe("Audio file path."),
2467
+ trackId: z.number().min(1).max(8).describe("Looper track (1-8)."),
2468
+ },
2469
+ }, async ({ filePath, trackId }) => {
2470
+ const check = await requireBridge();
2471
+ if (check !== true)
2472
+ return check;
2473
+ await bridge.send("loadAudioToLooperTrack", filePath, trackId);
2474
+ return text(`Loaded ${filePath} into looper track ${trackId}.`);
2475
+ });
2476
+ server.registerTool("request_looper_warp", {
2477
+ title: "Request Looper Warp",
2478
+ description: "Render an offline warp for a looper track's clip at the current host BPM. " +
2479
+ "Result lands in state.loopers[cellId].warpedClipPath when ready.",
2480
+ inputSchema: {
2481
+ cellId: z.number().min(1).max(8).describe("Looper track (1-8)."),
2482
+ },
2483
+ }, async ({ cellId }) => {
2484
+ const check = await requireBridge();
2485
+ if (check !== true)
2486
+ return check;
2487
+ await bridge.send("requestLooperWarp", cellId);
2488
+ return text(`Requested offline warp for looper track ${cellId}.`);
2489
+ });
2490
+ server.registerTool("looper_settings", {
2491
+ title: "Looper Settings",
2492
+ description: "Configure looper-wide behavior: always-listen rolling buffer, input-latency compensation.",
2493
+ inputSchema: {
2494
+ alwaysListen: z
2495
+ .boolean()
2496
+ .optional()
2497
+ .describe("Keep capturing audio into a rolling buffer (required for capture_looper_now)."),
2498
+ latencyCompAuto: z
2499
+ .boolean()
2500
+ .optional()
2501
+ .describe("Auto-compensate for input latency (defaults ON for external input)."),
2502
+ latencyCompOffsetMs: z
2503
+ .number()
2504
+ .min(-500)
2505
+ .max(500)
2506
+ .optional()
2507
+ .describe("Manual fine-tune (-500..+500 ms)."),
2508
+ latencyCompApplyToCells: z
2509
+ .boolean()
2510
+ .optional()
2511
+ .describe("Apply latency comp also when capture source is a cell output."),
2512
+ },
2513
+ }, async (args) => {
2514
+ const check = await requireBridge();
2515
+ if (check !== true)
2516
+ return check;
2517
+ const updates = [];
2518
+ if (typeof args.alwaysListen === "boolean") {
2519
+ await bridge.send("setLooperAlwaysListen", args.alwaysListen);
2520
+ updates.push(`alwaysListen=${args.alwaysListen}`);
2521
+ }
2522
+ if (typeof args.latencyCompAuto === "boolean") {
2523
+ await bridge.send("setLooperLatencyCompAuto", args.latencyCompAuto);
2524
+ updates.push(`latencyCompAuto=${args.latencyCompAuto}`);
2525
+ }
2526
+ if (typeof args.latencyCompOffsetMs === "number") {
2527
+ await bridge.send("setLooperLatencyCompOffsetMs", args.latencyCompOffsetMs);
2528
+ updates.push(`latencyCompOffsetMs=${args.latencyCompOffsetMs}`);
2529
+ }
2530
+ if (typeof args.latencyCompApplyToCells === "boolean") {
2531
+ await bridge.send("setLooperLatencyCompApplyToCells", args.latencyCompApplyToCells);
2532
+ updates.push(`latencyCompApplyToCells=${args.latencyCompApplyToCells}`);
2533
+ }
2534
+ return text(updates.length ? `Looper settings updated: ${updates.join(", ")}` : "No looper settings provided.");
2535
+ });
2536
+ server.registerTool("looper_variation", {
2537
+ title: "Looper Variation",
2538
+ description: "Manage looper variation slots (clip-set snapshots). Operations: 'apply' (immediate), 'queue' (bar-synced), " +
2539
+ "'cancel', 'clear', 'setCurrent', 'save' (snapshot current looper state into the slot).",
2540
+ inputSchema: {
2541
+ op: z
2542
+ .enum(["apply", "queue", "cancel", "clear", "setCurrent", "save"])
2543
+ .describe("Operation to perform."),
2544
+ index: z
2545
+ .number()
2546
+ .min(0)
2547
+ .max(7)
2548
+ .optional()
2549
+ .describe("Variation slot 0-7 (required for apply/queue/clear/setCurrent/save)."),
2550
+ intervalBars: z
2551
+ .number()
2552
+ .min(0.125)
2553
+ .max(64)
2554
+ .optional()
2555
+ .describe("Bar interval for 'queue' (required for queue)."),
2556
+ fadeMs: z.number().min(0).max(2000).optional().describe("Crossfade ms (default 20)."),
2557
+ data: z
2558
+ .record(z.string(), z.unknown())
2559
+ .optional()
2560
+ .describe("For 'save': the looper variation snapshot (object). Pass null to clear instead."),
2561
+ },
2562
+ }, async (args) => {
2563
+ const check = await requireBridge();
2564
+ if (check !== true)
2565
+ return check;
2566
+ switch (args.op) {
2567
+ case "apply":
2568
+ if (args.index === undefined)
2569
+ return error("'apply' requires index");
2570
+ await bridge.send("applyLooperVariationImmediate", args.index);
2571
+ return text(`Applied looper variation ${args.index}.`);
2572
+ case "queue":
2573
+ if (args.index === undefined || args.intervalBars === undefined)
2574
+ return error("'queue' requires index + intervalBars");
2575
+ await bridge.send("queueLooperVariationOnBars", {
2576
+ index: args.index,
2577
+ intervalBars: args.intervalBars,
2578
+ fadeMs: args.fadeMs ?? 20,
2579
+ });
2580
+ return text(`Queued looper variation ${args.index} on next ${args.intervalBars}-bar boundary.`);
2581
+ case "cancel":
2582
+ await bridge.send("cancelQueuedLooperVariation");
2583
+ return text("Cancelled queued looper variation.");
2584
+ case "clear":
2585
+ if (args.index === undefined)
2586
+ return error("'clear' requires index");
2587
+ await bridge.send("clearLooperVariation", args.index);
2588
+ return text(`Cleared looper variation slot ${args.index}.`);
2589
+ case "setCurrent":
2590
+ if (args.index === undefined)
2591
+ return error("'setCurrent' requires index");
2592
+ await bridge.send("setCurrentLooperVariationIndex", args.index);
2593
+ return text(`Current looper variation slot set to ${args.index}.`);
2594
+ case "save":
2595
+ if (args.index === undefined)
2596
+ return error("'save' requires index");
2597
+ await bridge.send("saveLooperVariation", { index: args.index, data: args.data ?? null });
2598
+ return text(args.data
2599
+ ? `Saved looper variation slot ${args.index}.`
2600
+ : `Cleared looper variation slot ${args.index} (no data).`);
2601
+ }
2602
+ return text("No-op");
2603
+ });
2604
+ server.registerTool("looper_variation_sync", {
2605
+ title: "Looper Variation Bar-Sync",
2606
+ description: "Configure how looper variation switches synchronize to bar boundaries.",
2607
+ inputSchema: {
2608
+ barSyncEnabled: z.boolean().optional().describe("Toggle bar-sync mode."),
2609
+ intervalBars: z.number().min(0.125).max(64).optional().describe("Sync interval in bars."),
2610
+ },
2611
+ }, async ({ barSyncEnabled, intervalBars }) => {
2612
+ const check = await requireBridge();
2613
+ if (check !== true)
2614
+ return check;
2615
+ const updates = [];
2616
+ if (typeof barSyncEnabled === "boolean") {
2617
+ await bridge.send("setLooperVariationBarSyncEnabled", barSyncEnabled);
2618
+ updates.push(`barSyncEnabled=${barSyncEnabled}`);
2619
+ }
2620
+ if (typeof intervalBars === "number") {
2621
+ await bridge.send("setLooperVariationBarSyncIntervalBars", intervalBars);
2622
+ updates.push(`intervalBars=${intervalBars}`);
2623
+ }
2624
+ return text(updates.length ? `Looper variation sync: ${updates.join(", ")}` : "No values provided.");
2625
+ });
2626
+ // ============================================================
2627
+ // TOOLS — Bar-synced variation scheduling + variation hold
2628
+ // ============================================================
2629
+ server.registerTool("queue_variation", {
2630
+ title: "Queue Variation Switch",
2631
+ description: "Schedule a global variation switch on the next bar boundary. " +
2632
+ "For an immediate switch, use load_variation instead.",
2633
+ inputSchema: {
2634
+ index: z.number().min(0).max(7).describe("Variation slot 0-7."),
2635
+ intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
2636
+ fadeMs: z.number().min(0).max(2000).default(20).describe("Crossfade duration in ms."),
2637
+ resetPhase: z
2638
+ .boolean()
2639
+ .default(true)
2640
+ .describe("Reset pattern phase on switch."),
2641
+ },
2642
+ }, async ({ index, intervalBars, fadeMs, resetPhase }) => {
2643
+ const check = await requireBridge();
2644
+ if (check !== true)
2645
+ return check;
2646
+ await bridge.send("queueVariationOnBars", { index, intervalBars, fadeMs, resetPhase });
2647
+ return text(`Queued variation ${index} on next ${intervalBars}-bar boundary.`);
2648
+ });
2649
+ server.registerTool("cancel_queued_variation", {
2650
+ title: "Cancel Queued Variation",
2651
+ description: "Cancel a pending bar-synced variation switch.",
2652
+ inputSchema: {},
2653
+ }, async () => {
2654
+ const check = await requireBridge();
2655
+ if (check !== true)
2656
+ return check;
2657
+ await bridge.send("cancelQueuedVariation");
2658
+ return text("Cancelled queued variation.");
2659
+ });
2660
+ server.registerTool("update_variations", {
2661
+ title: "Update Variations Table",
2662
+ description: "Replace the entire 8-slot variations table with a new array. " +
2663
+ "Each entry is a JSON-stringified variation snapshot (or null for an empty slot). " +
2664
+ "Most agents should prefer save_variation for individual slots.",
2665
+ inputSchema: {
2666
+ variations: z
2667
+ .array(z.union([z.string(), z.null()]))
2668
+ .length(8)
2669
+ .describe("Array of 8 entries (JSON string or null)."),
2670
+ },
2671
+ }, async ({ variations }) => {
2672
+ const check = await requireBridge();
2673
+ if (check !== true)
2674
+ return check;
2675
+ await bridge.send("updateVariations", variations);
2676
+ return text("Updated variations table.");
2677
+ });
2678
+ server.registerTool("variation_hold", {
2679
+ title: "Variation Hold",
2680
+ description: "'Hold while note pressed' mode for variations. Use this to map MIDI notes to momentary " +
2681
+ "variation switches that revert when the note releases.",
2682
+ inputSchema: {
2683
+ enabled: z.boolean().optional().describe("Toggle hold mode on/off."),
2684
+ allNotesOff: z
2685
+ .boolean()
2686
+ .optional()
2687
+ .describe("Force-release any held trigger notes (useful after a stuck MIDI note)."),
2688
+ bindings: z
2689
+ .array(z.object({
2690
+ triggerNote: z.number().min(-1).max(127).describe("MIDI note (-1 unassigned)."),
2691
+ triggerChannel: z.number().min(0).max(16).describe("MIDI channel (0=omni, 1-16)."),
2692
+ }))
2693
+ .max(8)
2694
+ .optional()
2695
+ .describe("Per-variation-slot MIDI bindings (slot 0..7). Array is padded with unassigned slots if shorter than 8."),
2696
+ },
2697
+ }, async ({ enabled, allNotesOff, bindings }) => {
2698
+ const check = await requireBridge();
2699
+ if (check !== true)
2700
+ return check;
2701
+ const updates = [];
2702
+ if (typeof enabled === "boolean") {
2703
+ await bridge.send("setVariationHoldEnabled", enabled);
2704
+ updates.push(`enabled=${enabled}`);
2705
+ }
2706
+ if (bindings) {
2707
+ await bridge.send("syncVariationHoldBindings", bindings);
2708
+ updates.push(`bindings=${bindings.length}`);
2709
+ }
2710
+ if (allNotesOff) {
2711
+ await bridge.send("variationHoldAllNotesOff");
2712
+ updates.push("allNotesOff");
2713
+ }
2714
+ return text(updates.length ? `Variation hold: ${updates.join(", ")}` : "No updates provided.");
2715
+ });
2716
+ // ============================================================
2717
+ // TOOLS — MIDI CC, Program Change, Recording
2718
+ // ============================================================
2719
+ server.registerTool("trigger_midi_cc", {
2720
+ title: "Trigger MIDI CC",
2721
+ description: "Send a MIDI Control Change message into PAM's MIDI router. " +
2722
+ "Maps to any MIDI-learned parameter or looper/variation CC bindings.",
2723
+ inputSchema: {
2724
+ channel: z.number().min(1).max(16).describe("MIDI channel (1-16)."),
2725
+ controller: z.number().min(0).max(127).describe("CC number (0-127)."),
2726
+ value: z.number().min(0).max(127).describe("CC value (0-127)."),
2727
+ },
2728
+ }, async ({ channel, controller, value }) => {
2729
+ const check = await requireBridge();
2730
+ if (check !== true)
2731
+ return check;
2732
+ await bridge.send("midiCC", channel, controller, value);
2733
+ return text(`MIDI CC: ch${channel} cc${controller}=${value}`);
2734
+ });
2735
+ server.registerTool("trigger_midi_program_change", {
2736
+ title: "Trigger MIDI Program Change",
2737
+ description: "Send a MIDI Program Change message (e.g. for preset switching).",
2738
+ inputSchema: {
2739
+ channel: z.number().min(1).max(16).describe("MIDI channel (1-16)."),
2740
+ program: z.number().min(0).max(127).describe("Program number (0-127)."),
2741
+ },
2742
+ }, async ({ channel, program }) => {
2743
+ const check = await requireBridge();
2744
+ if (check !== true)
2745
+ return check;
2746
+ await bridge.send("midiProgramChange", channel, program);
2747
+ return text(`MIDI PC: ch${channel} program=${program}`);
2748
+ });
2749
+ server.registerTool("midi_recording", {
2750
+ title: "MIDI Recording",
2751
+ description: "Control live MIDI recording into the active sequencer. " +
2752
+ "Ops: 'start' (optional opts), 'stop' (with commit flag), 'setOverdub', 'removeNote'.",
2753
+ inputSchema: {
2754
+ op: z
2755
+ .enum(["start", "stop", "setOverdub", "removeNote"])
2756
+ .describe("Operation."),
2757
+ overdub: z
2758
+ .boolean()
2759
+ .optional()
2760
+ .describe("For 'start' or 'setOverdub': overdub vs replace."),
2761
+ commit: z
2762
+ .boolean()
2763
+ .optional()
2764
+ .describe("For 'stop': commit (true, default) or discard (false)."),
2765
+ cellId: z.number().min(1).max(8).optional().describe("For 'removeNote': cell."),
2766
+ time: z.number().optional().describe("For 'removeNote': note time (beats)."),
2767
+ },
2768
+ }, async (args) => {
2769
+ const check = await requireBridge();
2770
+ if (check !== true)
2771
+ return check;
2772
+ switch (args.op) {
2773
+ case "start": {
2774
+ const opts = {};
2775
+ if (typeof args.overdub === "boolean")
2776
+ opts.overdub = args.overdub;
2777
+ await bridge.send("startMidiRecording", opts);
2778
+ return text("Started MIDI recording.");
2779
+ }
2780
+ case "stop":
2781
+ await bridge.send("stopMidiRecording", { commit: args.commit ?? true });
2782
+ return text(`Stopped MIDI recording (commit=${args.commit ?? true}).`);
2783
+ case "setOverdub":
2784
+ if (typeof args.overdub !== "boolean")
2785
+ return error("'setOverdub' requires overdub");
2786
+ await bridge.send("setMidiRecordingOverdub", args.overdub);
2787
+ return text(`MIDI recording overdub=${args.overdub}.`);
2788
+ case "removeNote":
2789
+ if (typeof args.cellId !== "number" || typeof args.time !== "number")
2790
+ return error("'removeNote' requires cellId + time");
2791
+ await bridge.send("removeRecordedNote", { cellId: args.cellId, time: args.time });
2792
+ return text(`Removed recorded note: cell ${args.cellId} at t=${args.time}.`);
2793
+ }
2794
+ return text("No-op");
2795
+ });
2796
+ // ============================================================
2797
+ // TOOLS — Modulation updates (LFO/Env/Vary/Macro/ParamSeq)
2798
+ // ============================================================
2799
+ server.registerTool("update_modulation", {
2800
+ title: "Update Modulation",
2801
+ description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, or ParamSeq sequence data. " +
2802
+ "The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
2803
+ "Use add_modulation to first attach a source to a target — then this tool to edit it.",
2804
+ inputSchema: {
2805
+ type: z
2806
+ .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
2807
+ .describe("Which modulation system to update."),
2808
+ payload: z
2809
+ .record(z.string(), z.unknown())
2810
+ .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[] }, etc."),
2811
+ },
2812
+ }, async ({ type, payload }) => {
2813
+ const check = await requireBridge();
2814
+ if (check !== true)
2815
+ return check;
2816
+ const data = JSON.stringify(payload);
2817
+ const action = type === "lfo"
2818
+ ? "applyLfoUpdate"
2819
+ : type === "envelope"
2820
+ ? "applyEnvelopeUpdate"
2821
+ : type === "vary"
2822
+ ? "applyVaryUpdate"
2823
+ : type === "macro"
2824
+ ? "applyMacroUpdate"
2825
+ : type === "paramSeq"
2826
+ ? "applyParamSeqUpdate"
2827
+ : "applyParamSeqSequenceUpdate";
2828
+ await bridge.send(action, data);
2829
+ return text(`Applied ${type} update.`);
2830
+ });
2831
+ // ============================================================
2832
+ // TOOLS — Song mode
2833
+ // ============================================================
2834
+ server.registerTool("song_mode", {
2835
+ title: "Song Mode",
2836
+ description: "Control PAM's song mode (timeline of variation/preset steps). Ops: 'setSequence', 'clear', " +
2837
+ "'toggle', 'setLoopPoints', 'toggleLoop', 'setCrossfadeMs', 'setPlayhead'.",
2838
+ inputSchema: {
2839
+ op: z
2840
+ .enum([
2841
+ "setSequence",
2842
+ "clear",
2843
+ "toggle",
2844
+ "setLoopPoints",
2845
+ "toggleLoop",
2846
+ "setCrossfadeMs",
2847
+ "setPlayhead",
2848
+ ])
2849
+ .describe("Operation."),
2850
+ steps: z
2851
+ .array(z.object({
2852
+ variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
2853
+ startPositionPPQ: z.number().describe("Start position in PPQ (4 PPQ = 1 bar at 4/4)."),
2854
+ endPositionPPQ: z.number().optional().describe("End position in PPQ."),
2855
+ presetPath: z
2856
+ .string()
2857
+ .optional()
2858
+ .describe("If non-empty, load this preset instead of switching variation."),
2859
+ muted: z.boolean().optional(),
2860
+ probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
2861
+ }))
2862
+ .optional()
2863
+ .describe("For 'setSequence': step list."),
2864
+ enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop': enabled state."),
2865
+ start: z
2866
+ .number()
2867
+ .nullable()
2868
+ .optional()
2869
+ .describe("For 'setLoopPoints': loop start PPQ (null clears)."),
2870
+ end: z
2871
+ .number()
2872
+ .nullable()
2873
+ .optional()
2874
+ .describe("For 'setLoopPoints': loop end PPQ (null clears)."),
2875
+ crossfadeMs: z
2876
+ .number()
2877
+ .min(0)
2878
+ .max(2000)
2879
+ .optional()
2880
+ .describe("For 'setCrossfadeMs'."),
2881
+ ppq: z.number().optional().describe("For 'setPlayhead': PPQ position to scrub to."),
2882
+ },
2883
+ }, async (args) => {
2884
+ const check = await requireBridge();
2885
+ if (check !== true)
2886
+ return check;
2887
+ switch (args.op) {
2888
+ case "setSequence":
2889
+ if (!args.steps)
2890
+ return error("'setSequence' requires steps");
2891
+ await bridge.send("setSongModeSequence", args.steps);
2892
+ return text(`Song mode sequence set (${args.steps.length} steps).`);
2893
+ case "clear":
2894
+ await bridge.send("clearSongModeSequence");
2895
+ return text("Cleared song mode sequence.");
2896
+ case "toggle":
2897
+ if (typeof args.enabled !== "boolean")
2898
+ return error("'toggle' requires enabled");
2899
+ await bridge.send("toggleSongMode", args.enabled);
2900
+ return text(`Song mode ${args.enabled ? "enabled" : "disabled"}.`);
2901
+ case "setLoopPoints":
2902
+ await bridge.send("setSongModeLoopPoints", {
2903
+ start: args.start ?? null,
2904
+ end: args.end ?? null,
2905
+ });
2906
+ return text(`Song mode loop: [${args.start ?? "null"}..${args.end ?? "null"}].`);
2907
+ case "toggleLoop":
2908
+ if (typeof args.enabled !== "boolean")
2909
+ return error("'toggleLoop' requires enabled");
2910
+ await bridge.send("toggleSongModeLoop", args.enabled);
2911
+ return text(`Song mode loop ${args.enabled ? "on" : "off"}.`);
2912
+ case "setCrossfadeMs":
2913
+ if (typeof args.crossfadeMs !== "number")
2914
+ return error("'setCrossfadeMs' requires crossfadeMs");
2915
+ await bridge.send("setSongModeCrossfadeMs", args.crossfadeMs);
2916
+ return text(`Song mode crossfade = ${args.crossfadeMs}ms.`);
2917
+ case "setPlayhead":
2918
+ if (typeof args.ppq !== "number")
2919
+ return error("'setPlayhead' requires ppq");
2920
+ await bridge.send("setSongModePlayhead", args.ppq);
2921
+ return text(`Song mode playhead → PPQ ${args.ppq}.`);
2922
+ }
2923
+ return text("No-op");
2924
+ });
2925
+ // ============================================================
2926
+ // TOOLS — Metronome
2927
+ // ============================================================
2928
+ server.registerTool("set_metronome", {
2929
+ title: "Set Metronome",
2930
+ description: "Toggle PAM's internal metronome on or off.",
2931
+ inputSchema: {
2932
+ enabled: z.boolean().describe("Metronome enabled?"),
2933
+ },
2934
+ }, async ({ enabled }) => {
2935
+ const check = await requireBridge();
2936
+ if (check !== true)
2937
+ return check;
2938
+ await bridge.send("setMetronomeEnabled", enabled);
2939
+ return text(`Metronome ${enabled ? "on" : "off"}.`);
2940
+ });
2941
+ // ============================================================
2942
+ // TOOLS — Sample analysis + ops
2943
+ // ============================================================
2944
+ server.registerTool("analyze_sample_tempo", {
2945
+ title: "Analyze Sample Tempo",
2946
+ description: "Run BPM detection and transient/slice analysis on an audio file. " +
2947
+ "Result is dispatched asynchronously into PAM's state — fetch it via get_state after a short delay (~100-500ms). " +
2948
+ "Use this before resample_sample_to_bpm if you don't already know the source BPM.",
2949
+ inputSchema: {
2950
+ filePath: z.string().describe("Path to the audio file."),
2951
+ transientSensitivity: z
2952
+ .number()
2953
+ .min(0)
2954
+ .max(100)
2955
+ .default(50)
2956
+ .describe("Onset-detection sensitivity (0..100)."),
2957
+ sliceStartNorm: z
2958
+ .number()
2959
+ .min(0)
2960
+ .max(1)
2961
+ .default(0)
2962
+ .describe("Analysis window start (0..1 normalized)."),
2963
+ sliceEndNorm: z
2964
+ .number()
2965
+ .min(0)
2966
+ .max(1)
2967
+ .default(1)
2968
+ .describe("Analysis window end (0..1 normalized)."),
2969
+ maxSlices: z
2970
+ .number()
2971
+ .min(1)
2972
+ .max(64)
2973
+ .default(64)
2974
+ .describe("Cap on number of detected slices."),
2975
+ },
2976
+ }, async (args) => {
2977
+ const check = await requireBridge();
2978
+ if (check !== true)
2979
+ return check;
2980
+ await bridge.send("analyzeSampleTempo", args.filePath, args.transientSensitivity, args.sliceStartNorm, args.sliceEndNorm, args.maxSlices);
2981
+ return text(`Queued tempo analysis for ${args.filePath}.`);
2982
+ });
2983
+ server.registerTool("get_sample_peaks", {
2984
+ title: "Get Sample Peaks",
2985
+ description: "Read a sample's waveform peak data for display/analysis. Returns an array of min/max peaks at the requested resolution. " +
2986
+ "Searches VFS first, then loads from disk.",
2987
+ inputSchema: {
2988
+ samplePath: z.string().describe("Sample path (relative or absolute)."),
2989
+ samplesPerPixel: z
2990
+ .number()
2991
+ .min(1)
2992
+ .optional()
2993
+ .describe("Resolution control — if set, peaks downsampled to ~ numSamples/samplesPerPixel bins (clamped 64..8192). Default 0 = 8192 pixels."),
2994
+ },
2995
+ annotations: { readOnlyHint: true },
2996
+ }, async ({ samplePath, samplesPerPixel }) => {
2997
+ const check = await requireBridge();
2998
+ if (check !== true)
2999
+ return check;
3000
+ const result = await bridge.send("requestSamplePeaks", samplePath, samplesPerPixel ?? 0);
3001
+ return json(result);
3002
+ });
3003
+ server.registerTool("preview_slice", {
3004
+ title: "Preview Slice",
3005
+ description: "Audition a single slice of a cell's sample.",
3006
+ inputSchema: {
3007
+ cellId: z.number().min(1).max(8),
3008
+ sliceNumber: z.number().min(0).describe("Slice index."),
3009
+ },
3010
+ }, async ({ cellId, sliceNumber }) => {
3011
+ const check = await requireBridge();
3012
+ if (check !== true)
3013
+ return check;
3014
+ await bridge.send("previewSlice", cellId, sliceNumber);
3015
+ return text(`Previewed slice ${sliceNumber} of cell ${cellId}.`);
3016
+ });
3017
+ server.registerTool("resample_sample_to_bpm", {
3018
+ title: "Resample Sample To BPM",
3019
+ description: "Time-stretch a cell's sample to the current project BPM (or a target BPM). " +
3020
+ "Non-destructive — use restore_original_sample to revert.",
3021
+ inputSchema: {
3022
+ cellId: z.number().min(1).max(8),
3023
+ sourceBpm: z
3024
+ .number()
3025
+ .min(20)
3026
+ .max(999)
3027
+ .optional()
3028
+ .describe("Source BPM. If omitted, PAM uses the analyzed/declared BPM in state."),
3029
+ targetBpm: z
3030
+ .number()
3031
+ .min(20)
3032
+ .max(999)
3033
+ .optional()
3034
+ .describe("Target BPM (defaults to current project BPM)."),
3035
+ },
3036
+ }, async (args) => {
3037
+ const check = await requireBridge();
3038
+ if (check !== true)
3039
+ return check;
3040
+ await bridge.send("resampleSampleToBPM", args);
3041
+ return text(`Resampled cell ${args.cellId} to BPM.`);
3042
+ });
3043
+ server.registerTool("restore_original_sample", {
3044
+ title: "Restore Original Sample",
3045
+ description: "Revert a cell's sample to its un-edited source (undoes resample / trim / etc.).",
3046
+ inputSchema: {
3047
+ cellId: z.number().min(1).max(8),
3048
+ },
3049
+ }, async ({ cellId }) => {
3050
+ const check = await requireBridge();
3051
+ if (check !== true)
3052
+ return check;
3053
+ await bridge.send("restoreOriginalSample", { cellId });
3054
+ return text(`Restored original sample for cell ${cellId}.`);
3055
+ });
3056
+ server.registerTool("build_sample_chain", {
3057
+ title: "Build Sample Chain",
3058
+ description: "Concatenate multiple audio files into one chained sample loaded onto a cell. Useful for sample-flip kits.",
3059
+ inputSchema: {
3060
+ filePaths: z.array(z.string()).min(1).describe("Audio files in chain order."),
3061
+ cellId: z.number().min(1).max(8).describe("Destination cell."),
3062
+ },
3063
+ }, async ({ filePaths, cellId }) => {
3064
+ const check = await requireBridge();
3065
+ if (check !== true)
3066
+ return check;
3067
+ const result = await bridge.send("buildSampleChain", filePaths, cellId);
3068
+ return json(result);
3069
+ });
3070
+ server.registerTool("export_slice", {
3071
+ title: "Export Slice",
3072
+ description: "Export a single slice of a cell's sample as a standalone WAV.",
3073
+ inputSchema: {
3074
+ cellId: z.number().min(1).max(8),
3075
+ sliceIndex: z.number().min(0),
3076
+ },
3077
+ }, async ({ cellId, sliceIndex }) => {
3078
+ const check = await requireBridge();
3079
+ if (check !== true)
3080
+ return check;
3081
+ await bridge.send("exportSliceForCell", { cellId, sliceIndex });
3082
+ return text(`Exported slice ${sliceIndex} of cell ${cellId}.`);
3083
+ });
3084
+ // ============================================================
3085
+ // TOOLS — Live resample (capture graph output into a cell)
3086
+ // ============================================================
3087
+ server.registerTool("live_resample", {
3088
+ title: "Live Resample",
3089
+ description: "Capture PAM's audio graph output into a cell as a new sample. Ops: 'start' / 'stop'. " +
3090
+ "Transport must be playing for the capture to produce audio.",
3091
+ inputSchema: {
3092
+ op: z.enum(["start", "stop"]).describe("Operation."),
3093
+ cellId: z
3094
+ .number()
3095
+ .min(1)
3096
+ .max(8)
3097
+ .optional()
3098
+ .describe("For 'start': destination cell."),
3099
+ bars: z
3100
+ .number()
3101
+ .min(0.125)
3102
+ .max(64)
3103
+ .optional()
3104
+ .describe("For 'start': capture length in bars."),
3105
+ finalize: z
3106
+ .boolean()
3107
+ .default(true)
3108
+ .describe("For 'stop': finalize the captured clip (false discards)."),
3109
+ },
3110
+ }, async (args) => {
3111
+ const check = await requireBridge();
3112
+ if (check !== true)
3113
+ return check;
3114
+ if (args.op === "start") {
3115
+ if (typeof args.cellId !== "number" || typeof args.bars !== "number")
3116
+ return error("'start' requires cellId + bars");
3117
+ await bridge.send("resampleGraphToCell", JSON.stringify({ cellId: args.cellId, bars: args.bars }));
3118
+ return text(`Started live resample → cell ${args.cellId} (${args.bars} bars).`);
3119
+ }
3120
+ await bridge.send("stopLiveResample", args.finalize ?? true);
3121
+ return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
3122
+ });
3123
+ // ============================================================
2162
3124
  // Start
2163
3125
  // ============================================================
2164
3126
  async function main() {
3127
+ if (await handleCliArgs())
3128
+ return;
2165
3129
  // Try to connect to plugin on startup (non-blocking)
2166
3130
  void bridge.connect();
2167
3131
  const transport = new StdioServerTransport();