@mthines/reaper-mcp 0.19.0-beta.20.1 → 0.19.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/main.js CHANGED
@@ -27,10 +27,10 @@ var SCOPE_NAME = "reaper-mcp-server";
27
27
  function readServiceVersion() {
28
28
  try {
29
29
  const require2 = createRequire(import.meta.url);
30
- const __dirname2 = dirname(fileURLToPath(import.meta.url));
30
+ const __dirname3 = dirname(fileURLToPath(import.meta.url));
31
31
  const pkgPaths = [
32
- join(__dirname2, "..", "package.json"),
33
- join(__dirname2, "package.json")
32
+ join(__dirname3, "..", "package.json"),
33
+ join(__dirname3, "package.json")
34
34
  ];
35
35
  for (const p of pkgPaths) {
36
36
  try {
@@ -2232,6 +2232,288 @@ function registerCategoryTools(server) {
2232
2232
  );
2233
2233
  }
2234
2234
 
2235
+ // apps/reaper-mcp-server/src/tools/aesthetics.ts
2236
+ import { z as z17 } from "zod/v4";
2237
+ import { unlink as unlink2 } from "node:fs/promises";
2238
+ import { randomUUID as randomUUID2 } from "node:crypto";
2239
+
2240
+ // apps/reaper-mcp-server/src/sidecar.ts
2241
+ import { spawn } from "node:child_process";
2242
+ import { existsSync } from "node:fs";
2243
+ import { join as join3 } from "node:path";
2244
+ import { homedir as homedir2, platform as platform2 } from "node:os";
2245
+ var VENV_BIN = platform2() === "win32" ? "Scripts" : "bin";
2246
+ var VENV_PYTHON_NAME = platform2() === "win32" ? "python.exe" : "python";
2247
+ var SIDECAR_VENV_PATH = join3(homedir2(), ".reaper-mcp", "sidecar-venv");
2248
+ var SIDECAR_SCRIPT_PATH = join3(homedir2(), ".reaper-mcp", "sidecar", "server.py");
2249
+ var VENV_PYTHON = join3(SIDECAR_VENV_PATH, VENV_BIN, VENV_PYTHON_NAME);
2250
+ var _rawTimeout = Number(process.env["REAPER_MCP_SIDECAR_TIMEOUT_MS"]);
2251
+ var ANALYZE_TIMEOUT_MS = Number.isFinite(_rawTimeout) && _rawTimeout > 0 ? _rawTimeout : 6e4;
2252
+ var SidecarClientImpl = class {
2253
+ process = null;
2254
+ pending = /* @__PURE__ */ new Map();
2255
+ nextId = 1;
2256
+ lineBuffer = "";
2257
+ restarting = false;
2258
+ restartAttempted = false;
2259
+ /** Cached spawn promise — prevents double-spawn on concurrent cold-start calls. */
2260
+ spawnPromise = null;
2261
+ isAvailable() {
2262
+ return existsSync(SIDECAR_SCRIPT_PATH) && existsSync(VENV_PYTHON);
2263
+ }
2264
+ async analyze(wavPath, startTime, endTime) {
2265
+ if (!this.isAvailable()) {
2266
+ throw new Error(
2267
+ "Audio understanding sidecar not installed. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar"
2268
+ );
2269
+ }
2270
+ await this.ensureRunning();
2271
+ return new Promise((resolve, reject) => {
2272
+ const id = this.nextId++;
2273
+ const request = {
2274
+ jsonrpc: "2.0",
2275
+ id,
2276
+ method: "analyze",
2277
+ params: { path: wavPath, startTime, endTime }
2278
+ };
2279
+ const timer = setTimeout(() => {
2280
+ if (this.pending.delete(id)) {
2281
+ process.stderr.write(
2282
+ `[reaper-mcp] Sidecar analyze() timed out after ${ANALYZE_TIMEOUT_MS}ms \u2014 killing process
2283
+ `
2284
+ );
2285
+ if (this.process) {
2286
+ this.process.kill("SIGKILL");
2287
+ this.process = null;
2288
+ }
2289
+ reject(new Error(
2290
+ `Sidecar timeout after ${ANALYZE_TIMEOUT_MS}ms. Override with REAPER_MCP_SIDECAR_TIMEOUT_MS env var.`
2291
+ ));
2292
+ }
2293
+ }, ANALYZE_TIMEOUT_MS);
2294
+ this.pending.set(id, {
2295
+ resolve: (r) => {
2296
+ clearTimeout(timer);
2297
+ resolve(r);
2298
+ },
2299
+ reject: (e) => {
2300
+ clearTimeout(timer);
2301
+ reject(e);
2302
+ }
2303
+ });
2304
+ const line = JSON.stringify(request) + "\n";
2305
+ try {
2306
+ const proc = this.process;
2307
+ if (!proc || !proc.stdin) {
2308
+ throw new Error("Sidecar process not running");
2309
+ }
2310
+ proc.stdin.write(line);
2311
+ } catch (err) {
2312
+ clearTimeout(timer);
2313
+ this.pending.delete(id);
2314
+ reject(new Error(`Failed to write to sidecar stdin: ${err instanceof Error ? err.message : String(err)}`));
2315
+ }
2316
+ });
2317
+ }
2318
+ shutdown() {
2319
+ if (this.process) {
2320
+ this.process.kill("SIGTERM");
2321
+ this.process = null;
2322
+ }
2323
+ for (const [id, { reject }] of this.pending.entries()) {
2324
+ reject(new Error("Sidecar shut down"));
2325
+ this.pending.delete(id);
2326
+ }
2327
+ }
2328
+ // -------------------------------------------------------------------------
2329
+ // Private
2330
+ // -------------------------------------------------------------------------
2331
+ async ensureRunning() {
2332
+ if (this.process && !this.process.killed) return;
2333
+ if (!this.spawnPromise) {
2334
+ this.spawnPromise = this.spawn().finally(() => {
2335
+ this.spawnPromise = null;
2336
+ });
2337
+ }
2338
+ await this.spawnPromise;
2339
+ }
2340
+ async spawn() {
2341
+ const child = spawn(VENV_PYTHON, [SIDECAR_SCRIPT_PATH], {
2342
+ stdio: ["pipe", "pipe", "pipe"]
2343
+ });
2344
+ if (child.stdout) {
2345
+ child.stdout.setEncoding("utf8");
2346
+ child.stdout.on("data", (chunk) => this.onStdout(chunk));
2347
+ }
2348
+ if (child.stderr) {
2349
+ child.stderr.setEncoding("utf8");
2350
+ child.stderr.on("data", (data) => {
2351
+ process.stderr.write(`[reaper-mcp-sidecar] ${data}`);
2352
+ });
2353
+ }
2354
+ child.on("exit", (code) => {
2355
+ process.stderr.write(`[reaper-mcp] Sidecar process exited (code ${code})
2356
+ `);
2357
+ this.process = null;
2358
+ this.handleProcessDeath();
2359
+ });
2360
+ child.on("error", (err) => {
2361
+ process.stderr.write(`[reaper-mcp] Sidecar spawn error: ${err.message}
2362
+ `);
2363
+ this.process = null;
2364
+ this.handleProcessDeath();
2365
+ });
2366
+ this.process = child;
2367
+ this.lineBuffer = "";
2368
+ this.restarting = false;
2369
+ this.restartAttempted = false;
2370
+ }
2371
+ onStdout(chunk) {
2372
+ this.lineBuffer += chunk;
2373
+ let newlineIdx;
2374
+ while ((newlineIdx = this.lineBuffer.indexOf("\n")) !== -1) {
2375
+ const line = this.lineBuffer.slice(0, newlineIdx).trim();
2376
+ this.lineBuffer = this.lineBuffer.slice(newlineIdx + 1);
2377
+ if (line) this.onResponse(line);
2378
+ }
2379
+ }
2380
+ onResponse(line) {
2381
+ let response;
2382
+ try {
2383
+ response = JSON.parse(line);
2384
+ } catch {
2385
+ process.stderr.write(`[reaper-mcp] Sidecar returned malformed JSON: ${line}
2386
+ `);
2387
+ for (const [id, { reject }] of this.pending.entries()) {
2388
+ reject(new Error(`Sidecar returned malformed JSON: ${line}`));
2389
+ this.pending.delete(id);
2390
+ }
2391
+ return;
2392
+ }
2393
+ const pending = this.pending.get(response.id);
2394
+ if (!pending) {
2395
+ process.stderr.write(`[reaper-mcp] Sidecar response for unknown id ${response.id}
2396
+ `);
2397
+ return;
2398
+ }
2399
+ this.pending.delete(response.id);
2400
+ if (response.error) {
2401
+ pending.reject(new Error(response.error.message));
2402
+ } else if (response.result) {
2403
+ pending.resolve(response.result);
2404
+ } else {
2405
+ pending.reject(new Error("Sidecar response missing both result and error fields"));
2406
+ }
2407
+ }
2408
+ handleProcessDeath() {
2409
+ if (this.restarting || this.pending.size === 0) {
2410
+ return;
2411
+ }
2412
+ if (!this.restartAttempted) {
2413
+ this.restarting = true;
2414
+ this.restartAttempted = true;
2415
+ process.stderr.write("[reaper-mcp] Sidecar crashed; attempting one restart...\n");
2416
+ this.spawn().then(() => {
2417
+ for (const [id, { reject }] of this.pending.entries()) {
2418
+ reject(new Error("Sidecar restarted; please retry the operation"));
2419
+ this.pending.delete(id);
2420
+ }
2421
+ }).catch((err) => {
2422
+ for (const [id, { reject }] of this.pending.entries()) {
2423
+ reject(new Error(`Sidecar restart failed: ${err.message}. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar`));
2424
+ this.pending.delete(id);
2425
+ }
2426
+ });
2427
+ } else {
2428
+ for (const [id, { reject }] of this.pending.entries()) {
2429
+ reject(new Error("Python sidecar unavailable after restart attempt. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar"));
2430
+ this.pending.delete(id);
2431
+ }
2432
+ }
2433
+ }
2434
+ };
2435
+ var _singleton = null;
2436
+ function getSidecarClient() {
2437
+ if (!_singleton) {
2438
+ _singleton = new SidecarClientImpl();
2439
+ }
2440
+ return _singleton;
2441
+ }
2442
+
2443
+ // apps/reaper-mcp-server/src/tools/aesthetics.ts
2444
+ var SIDECAR_NOT_INSTALLED_MSG = "Audio understanding sidecar not installed. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar";
2445
+ function registerAestheticsTools(server) {
2446
+ server.tool(
2447
+ "analyze_track_aesthetics",
2448
+ "Analyze the perceptual aesthetic quality of a track's audio using Meta's Audiobox Aesthetics model (CC-BY 4.0). Bounces the track to a temp WAV file (post-FX, post-fader) and runs 4-axis perceptual quality scoring: Production Quality (PQ), Production Complexity (PC), Content Enjoyment (CE), Content Usefulness (CU). Returns scores 0-10. Requires the Python sidecar (run: node dist/apps/reaper-mcp-server/main.js setup-sidecar).",
2449
+ {
2450
+ trackIndex: z17.coerce.number().int().min(0).describe("Zero-based track index"),
2451
+ startTime: z17.coerce.number().min(0).optional().describe("Start time in seconds (default: 0 or use REAPER time selection)"),
2452
+ endTime: z17.coerce.number().min(0).optional().describe("End time in seconds (default: startTime + durationSeconds)"),
2453
+ durationSeconds: z17.coerce.number().min(0.5).max(30).optional().default(5).describe("Duration to analyze in seconds (default 5, max 30). Ignored if endTime is provided.")
2454
+ },
2455
+ async ({ trackIndex, startTime, endTime, durationSeconds }) => {
2456
+ const sidecar = getSidecarClient();
2457
+ if (!sidecar.isAvailable()) {
2458
+ return {
2459
+ content: [{ type: "text", text: SIDECAR_NOT_INSTALLED_MSG }],
2460
+ isError: true
2461
+ };
2462
+ }
2463
+ const resolvedStart = startTime ?? 0;
2464
+ const resolvedEnd = endTime ?? resolvedStart + (durationSeconds ?? 5);
2465
+ if (resolvedEnd <= resolvedStart) {
2466
+ return {
2467
+ content: [{ type: "text", text: "endTime must be greater than startTime" }],
2468
+ isError: true
2469
+ };
2470
+ }
2471
+ const commandId = randomUUID2();
2472
+ const renderRes = await sendCommand("render_track_to_wav", {
2473
+ trackIndex,
2474
+ startTime: resolvedStart,
2475
+ endTime: resolvedEnd,
2476
+ commandId
2477
+ });
2478
+ if (!renderRes.success) {
2479
+ return {
2480
+ content: [{ type: "text", text: `Render failed: ${renderRes.error}` }],
2481
+ isError: true
2482
+ };
2483
+ }
2484
+ const renderData = renderRes.data;
2485
+ const wavPath = renderData.wavPath;
2486
+ try {
2487
+ const scores = await sidecar.analyze(wavPath, resolvedStart, resolvedEnd);
2488
+ const result = {
2489
+ trackIndex,
2490
+ trackName: renderData.trackName,
2491
+ productionQuality: scores.PQ,
2492
+ productionComplexity: scores.PC,
2493
+ contentEnjoyment: scores.CE,
2494
+ contentUsefulness: scores.CU,
2495
+ startTime: resolvedStart,
2496
+ endTime: resolvedEnd,
2497
+ durationSeconds: resolvedEnd - resolvedStart,
2498
+ modelVersion: scores.modelVersion
2499
+ };
2500
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2501
+ } catch (err) {
2502
+ return {
2503
+ content: [{
2504
+ type: "text",
2505
+ text: err instanceof Error ? err.message : String(err)
2506
+ }],
2507
+ isError: true
2508
+ };
2509
+ } finally {
2510
+ await unlink2(wavPath).catch(() => {
2511
+ });
2512
+ }
2513
+ }
2514
+ );
2515
+ }
2516
+
2235
2517
  // apps/reaper-mcp-server/src/server.ts
2236
2518
  function instrumentToolHandlers(server) {
2237
2519
  const originalTool = server.tool.bind(server);
@@ -2291,40 +2573,43 @@ function createServer() {
2291
2573
  registerEnvelopeTools(server);
2292
2574
  registerBatchTools(server);
2293
2575
  registerCategoryTools(server);
2576
+ registerAestheticsTools(server);
2294
2577
  return server;
2295
2578
  }
2296
2579
 
2297
2580
  // apps/reaper-mcp-server/src/main.ts
2298
- import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
2299
- import { join as join5, dirname as dirname2 } from "node:path";
2300
- import { fileURLToPath as fileURLToPath2 } from "node:url";
2301
- import { homedir as homedir3 } from "node:os";
2581
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4 } from "node:fs";
2582
+ import { join as join7, dirname as dirname3 } from "node:path";
2583
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
2584
+ import { homedir as homedir5, platform as platform4 } from "node:os";
2585
+ import { exec as execCb } from "node:child_process";
2586
+ import { promisify as promisifyUtil } from "node:util";
2302
2587
 
2303
2588
  // apps/reaper-mcp-server/src/cli.ts
2304
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2305
- import { join as join3 } from "node:path";
2589
+ import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2590
+ import { join as join4 } from "node:path";
2306
2591
  function resolveAssetDir(baseDir, name) {
2307
- const sibling = join3(baseDir, name);
2308
- if (existsSync(sibling)) return sibling;
2309
- const parent = join3(baseDir, "..", name);
2310
- if (existsSync(parent)) return parent;
2592
+ const sibling = join4(baseDir, name);
2593
+ if (existsSync2(sibling)) return sibling;
2594
+ const parent = join4(baseDir, "..", name);
2595
+ if (existsSync2(parent)) return parent;
2311
2596
  let dir = baseDir;
2312
2597
  for (let i = 0; i < 5; i++) {
2313
- const candidate = join3(dir, name);
2314
- if (existsSync(candidate)) return candidate;
2315
- const up = join3(dir, "..");
2598
+ const candidate = join4(dir, name);
2599
+ if (existsSync2(candidate)) return candidate;
2600
+ const up = join4(dir, "..");
2316
2601
  if (up === dir) break;
2317
2602
  dir = up;
2318
2603
  }
2319
2604
  return sibling;
2320
2605
  }
2321
2606
  function copyDirSync(src, dest) {
2322
- if (!existsSync(src)) return 0;
2607
+ if (!existsSync2(src)) return 0;
2323
2608
  mkdirSync(dest, { recursive: true });
2324
2609
  let count = 0;
2325
2610
  for (const entry of readdirSync(src)) {
2326
- const srcPath = join3(src, entry);
2327
- const destPath = join3(dest, entry);
2611
+ const srcPath = join4(src, entry);
2612
+ const destPath = join4(dest, entry);
2328
2613
  if (statSync(srcPath).isDirectory()) {
2329
2614
  count += copyDirSync(srcPath, destPath);
2330
2615
  } else {
@@ -2335,14 +2620,14 @@ function copyDirSync(src, dest) {
2335
2620
  return count;
2336
2621
  }
2337
2622
  function installFile(src, dest) {
2338
- if (existsSync(src)) {
2623
+ if (existsSync2(src)) {
2339
2624
  copyFileSync(src, dest);
2340
2625
  return true;
2341
2626
  }
2342
2627
  return false;
2343
2628
  }
2344
2629
  function createMcpJson(targetPath) {
2345
- if (existsSync(targetPath)) return false;
2630
+ if (existsSync2(targetPath)) return false;
2346
2631
  const config = JSON.stringify({
2347
2632
  mcpServers: {
2348
2633
  reaper: {
@@ -2469,12 +2754,16 @@ var MCP_TOOL_NAMES = [
2469
2754
  // progressive discovery
2470
2755
  "list_tool_categories",
2471
2756
  "enable_tool_category",
2472
- "disable_tool_category"
2757
+ "disable_tool_category",
2758
+ // semantic audio analysis (requires Python sidecar — opt-in)
2759
+ "analyze_track_aesthetics"
2760
+ // Note: 'render_track_to_wav' is an internal Lua bridge command, NOT a public MCP tool.
2761
+ // It must NOT be listed here — MCP_TOOL_NAMES drives the Claude Code allowlist.
2473
2762
  ];
2474
2763
  function ensureClaudeSettings(settingsPath) {
2475
2764
  const allowList = MCP_TOOL_NAMES.map((t) => `mcp__reaper__${t}`);
2476
- if (!existsSync(settingsPath)) {
2477
- mkdirSync(join3(settingsPath, ".."), { recursive: true });
2765
+ if (!existsSync2(settingsPath)) {
2766
+ mkdirSync(join4(settingsPath, ".."), { recursive: true });
2478
2767
  const config = { permissions: { allow: allowList } };
2479
2768
  writeFileSync(settingsPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2480
2769
  return "created";
@@ -2491,21 +2780,21 @@ function ensureClaudeSettings(settingsPath) {
2491
2780
  }
2492
2781
  function resolveAssetDirWithFallback(baseDir, buildName, sourceName) {
2493
2782
  const resolved = resolveAssetDir(baseDir, buildName);
2494
- if (existsSync(resolved)) return resolved;
2783
+ if (existsSync2(resolved)) return resolved;
2495
2784
  return resolveAssetDir(baseDir, sourceName);
2496
2785
  }
2497
2786
 
2498
2787
  // apps/reaper-mcp-server/src/init.ts
2499
2788
  import { checkbox, select } from "@inquirer/prompts";
2500
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
2501
- import { join as join4 } from "node:path";
2502
- import { homedir as homedir2 } from "node:os";
2789
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "node:fs";
2790
+ import { join as join5 } from "node:path";
2791
+ import { homedir as homedir3 } from "node:os";
2503
2792
  async function runInit(opts, dirResolver) {
2504
2793
  const isTTY = Boolean(process.stdin.isTTY);
2505
2794
  const headless = opts.yes || !isTTY;
2506
2795
  console.log("REAPER MCP \u2014 Interactive Setup\n");
2507
2796
  const bridgeDir = await ensureBridgeDir();
2508
- console.log(`REAPER resource path: ${join4(bridgeDir, "..", "..")}
2797
+ console.log(`REAPER resource path: ${join5(bridgeDir, "..", "..")}
2509
2798
  `);
2510
2799
  let selections;
2511
2800
  if (headless) {
@@ -2550,27 +2839,27 @@ async function runInit(opts, dirResolver) {
2550
2839
  skillsScope
2551
2840
  };
2552
2841
  }
2553
- const __dirname2 = dirResolver();
2842
+ const __dirname3 = dirResolver();
2554
2843
  if (selections.bridge) {
2555
2844
  console.log("Installing REAPER Bridge...");
2556
2845
  const scriptsDir = getReaperScriptsPath();
2557
2846
  mkdirSync2(scriptsDir, { recursive: true });
2558
- const reaperDir = resolveAssetDir(__dirname2, "reaper");
2847
+ const reaperDir = resolveAssetDir(__dirname3, "reaper");
2559
2848
  for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
2560
- const src = join4(reaperDir, luaFile);
2561
- const dest = join4(scriptsDir, luaFile);
2849
+ const src = join5(reaperDir, luaFile);
2850
+ const dest = join5(scriptsDir, luaFile);
2562
2851
  if (installFile(src, dest)) {
2563
2852
  console.log(` Installed: ${luaFile}`);
2564
2853
  } else {
2565
2854
  console.log(` Not found: ${src}`);
2566
2855
  }
2567
2856
  }
2568
- const effectsDir = join4(getReaperEffectsPath(), "reaper-mcp");
2857
+ const effectsDir = join5(getReaperEffectsPath(), "reaper-mcp");
2569
2858
  mkdirSync2(effectsDir, { recursive: true });
2570
2859
  for (const asset of REAPER_ASSETS) {
2571
2860
  if (asset.endsWith(".lua")) continue;
2572
- const src = join4(reaperDir, asset);
2573
- const dest = join4(effectsDir, asset);
2861
+ const src = join5(reaperDir, asset);
2862
+ const dest = join5(effectsDir, asset);
2574
2863
  if (installFile(src, dest)) {
2575
2864
  console.log(` Installed: reaper-mcp/${asset}`);
2576
2865
  } else {
@@ -2581,32 +2870,32 @@ async function runInit(opts, dirResolver) {
2581
2870
  }
2582
2871
  if (selections.skills) {
2583
2872
  const isGlobal = selections.skillsScope === "global";
2584
- const baseDir = isGlobal ? join4(homedir2(), ".claude") : process.cwd();
2585
- const claudeDir = isGlobal ? baseDir : join4(baseDir, ".claude");
2873
+ const baseDir = isGlobal ? join5(homedir3(), ".claude") : process.cwd();
2874
+ const claudeDir = isGlobal ? baseDir : join5(baseDir, ".claude");
2586
2875
  console.log(`Installing AI Skills & Agents (${selections.skillsScope})...`);
2587
- const knowledgeSrc = resolveAssetDir(__dirname2, "knowledge");
2588
- if (existsSync2(knowledgeSrc)) {
2589
- const dest = join4(isGlobal ? join4(homedir2(), ".claude") : process.cwd(), "knowledge");
2876
+ const knowledgeSrc = resolveAssetDir(__dirname3, "knowledge");
2877
+ if (existsSync3(knowledgeSrc)) {
2878
+ const dest = join5(isGlobal ? join5(homedir3(), ".claude") : process.cwd(), "knowledge");
2590
2879
  const count = copyDirSync(knowledgeSrc, dest);
2591
2880
  console.log(` Installed knowledge base: ${count} files`);
2592
2881
  } else {
2593
2882
  console.log(" Knowledge base not found in package. Skipping.");
2594
2883
  }
2595
- const rulesSrc = resolveAssetDirWithFallback(__dirname2, "claude-rules", join4(".claude", "rules"));
2596
- if (existsSync2(rulesSrc)) {
2597
- const dest = join4(claudeDir, "rules");
2884
+ const rulesSrc = resolveAssetDirWithFallback(__dirname3, "claude-rules", join5(".claude", "rules"));
2885
+ if (existsSync3(rulesSrc)) {
2886
+ const dest = join5(claudeDir, "rules");
2598
2887
  const count = copyDirSync(rulesSrc, dest);
2599
2888
  console.log(` Installed Claude rules: ${count} files`);
2600
2889
  }
2601
- const skillsSrc = resolveAssetDirWithFallback(__dirname2, "claude-skills", join4(".claude", "skills"));
2602
- if (existsSync2(skillsSrc)) {
2603
- const dest = join4(claudeDir, "skills");
2890
+ const skillsSrc = resolveAssetDirWithFallback(__dirname3, "claude-skills", join5(".claude", "skills"));
2891
+ if (existsSync3(skillsSrc)) {
2892
+ const dest = join5(claudeDir, "skills");
2604
2893
  const count = copyDirSync(skillsSrc, dest);
2605
2894
  console.log(` Installed Claude skills: ${count} files`);
2606
2895
  }
2607
- const agentsSrc = resolveAssetDirWithFallback(__dirname2, "claude-agents", join4(".claude", "agents"));
2608
- if (existsSync2(agentsSrc)) {
2609
- const dest = join4(claudeDir, "agents");
2896
+ const agentsSrc = resolveAssetDirWithFallback(__dirname3, "claude-agents", join5(".claude", "agents"));
2897
+ if (existsSync3(agentsSrc)) {
2898
+ const dest = join5(claudeDir, "agents");
2610
2899
  const count = copyDirSync(agentsSrc, dest);
2611
2900
  console.log(` Installed Claude agents: ${count} files`);
2612
2901
  }
@@ -2614,8 +2903,8 @@ async function runInit(opts, dirResolver) {
2614
2903
  }
2615
2904
  if (selections.settings) {
2616
2905
  console.log("Configuring Claude Code Settings...");
2617
- const settingsDir = join4(homedir2(), ".claude");
2618
- const settingsPath = join4(settingsDir, "settings.json");
2906
+ const settingsDir = join5(homedir3(), ".claude");
2907
+ const settingsPath = join5(settingsDir, "settings.json");
2619
2908
  const result = ensureClaudeSettings(settingsPath);
2620
2909
  if (result === "created") {
2621
2910
  console.log(` Created: ${settingsPath}`);
@@ -2628,7 +2917,7 @@ async function runInit(opts, dirResolver) {
2628
2917
  }
2629
2918
  if (selections.projectConfig) {
2630
2919
  console.log("Creating Project Config...");
2631
- const mcpJsonPath = join4(process.cwd(), ".mcp.json");
2920
+ const mcpJsonPath = join5(process.cwd(), ".mcp.json");
2632
2921
  if (createMcpJson(mcpJsonPath)) {
2633
2922
  console.log(` Created: ${mcpJsonPath}`);
2634
2923
  } else {
@@ -2639,18 +2928,18 @@ async function runInit(opts, dirResolver) {
2639
2928
  console.log("Running system check...");
2640
2929
  const bridgeRunning = await isBridgeRunning();
2641
2930
  console.log(` Lua bridge: ${bridgeRunning ? "Connected" : "Not detected (start after REAPER is open)"}`);
2642
- const globalClaudeDir = join4(homedir2(), ".claude");
2643
- const localAgents = existsSync2(join4(process.cwd(), ".claude", "agents"));
2644
- const globalAgents = existsSync2(join4(globalClaudeDir, "agents"));
2931
+ const globalClaudeDir = join5(homedir3(), ".claude");
2932
+ const localAgents = existsSync3(join5(process.cwd(), ".claude", "agents"));
2933
+ const globalAgents = existsSync3(join5(globalClaudeDir, "agents"));
2645
2934
  const agentsExist = localAgents || globalAgents;
2646
2935
  console.log(` Mix agents: ${agentsExist ? "Installed" : "Not installed"}`);
2647
- const mcpJsonExists = existsSync2(join4(process.cwd(), ".mcp.json"));
2936
+ const mcpJsonExists = existsSync3(join5(process.cwd(), ".mcp.json"));
2648
2937
  console.log(` MCP config: ${mcpJsonExists ? ".mcp.json found" : ".mcp.json not present"}`);
2649
2938
  console.log("");
2650
2939
  console.log("Setup complete! Next steps:");
2651
2940
  if (selections.bridge) {
2652
2941
  const scriptsDir = getReaperScriptsPath();
2653
- const luaDest = join4(scriptsDir, "mcp_bridge.lua");
2942
+ const luaDest = join5(scriptsDir, "mcp_bridge.lua");
2654
2943
  console.log(" 1. Open REAPER");
2655
2944
  console.log(" 2. Actions > Show action list > Load ReaScript");
2656
2945
  console.log(` 3. Select: ${luaDest}`);
@@ -2665,33 +2954,206 @@ async function runInit(opts, dirResolver) {
2665
2954
  }
2666
2955
  }
2667
2956
 
2668
- // apps/reaper-mcp-server/src/main.ts
2957
+ // apps/reaper-mcp-server/src/setup-sidecar.ts
2958
+ import { exec as execImpl } from "node:child_process";
2959
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, copyFileSync as copyFileSync2, rmSync } from "node:fs";
2960
+ import { join as join6, dirname as dirname2 } from "node:path";
2961
+ import { homedir as homedir4, platform as platform3 } from "node:os";
2962
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2963
+ var VENV_BIN2 = platform3() === "win32" ? "Scripts" : "bin";
2964
+ var VENV_PYTHON_NAME2 = platform3() === "win32" ? "python.exe" : "python";
2965
+ var VENV_PIP_NAME = platform3() === "win32" ? "pip.exe" : "pip";
2966
+ function exec(cmd) {
2967
+ return new Promise((resolve, reject) => {
2968
+ execImpl(cmd, (err, stdout, stderr) => {
2969
+ if (err) reject(err);
2970
+ else resolve({ stdout, stderr });
2971
+ });
2972
+ });
2973
+ }
2669
2974
  var __dirname = dirname2(fileURLToPath2(import.meta.url));
2975
+ var SIDECAR_VENV_PATH2 = join6(homedir4(), ".reaper-mcp", "sidecar-venv");
2976
+ var SIDECAR_DIR = join6(homedir4(), ".reaper-mcp", "sidecar");
2977
+ var SIDECAR_SCRIPT_DEST = join6(SIDECAR_DIR, "server.py");
2978
+ var MODEL_ID = "facebook/audiobox-aesthetics";
2979
+ var MIN_PYTHON_MAJOR = 3;
2980
+ var MIN_PYTHON_MINOR = 10;
2981
+ function getPythonBin() {
2982
+ return process.env["PYTHON_BIN"] ?? "python3";
2983
+ }
2984
+ function parsePythonVersion(output) {
2985
+ const match = output.match(/Python\s+(\d+)\.(\d+)\.(\d+)/i);
2986
+ if (!match) return null;
2987
+ return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
2988
+ }
2989
+ async function checkPython(pythonBin) {
2990
+ let stdout;
2991
+ try {
2992
+ const result = await exec(`"${pythonBin}" --version`);
2993
+ stdout = (result.stdout + result.stderr).trim();
2994
+ } catch {
2995
+ const notFound = `Python interpreter not found: ${pythonBin}
2996
+ Install Python 3.10+ from https://python.org or set the PYTHON_BIN environment variable.
2997
+ Example: PYTHON_BIN=/usr/local/bin/python3.11 node dist/apps/reaper-mcp-server/main.js setup-sidecar`;
2998
+ throw new Error(notFound);
2999
+ }
3000
+ const version = parsePythonVersion(stdout);
3001
+ if (!version) {
3002
+ throw new Error(`Could not parse Python version from: "${stdout}". Is PYTHON_BIN set correctly?`);
3003
+ }
3004
+ const [major, minor, patch] = version;
3005
+ if (major < MIN_PYTHON_MAJOR || major === MIN_PYTHON_MAJOR && minor < MIN_PYTHON_MINOR) {
3006
+ throw new Error(
3007
+ `Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ required. Found: ${major}.${minor}.${patch} at ${pythonBin}.
3008
+ Install Python 3.10+ from https://python.org or set PYTHON_BIN to a newer interpreter.`
3009
+ );
3010
+ }
3011
+ return `${major}.${minor}.${patch}`;
3012
+ }
3013
+ async function createVenv(pythonBin) {
3014
+ mkdirSync3(join6(homedir4(), ".reaper-mcp"), { recursive: true });
3015
+ await exec(`"${pythonBin}" -m venv --clear "${SIDECAR_VENV_PATH2}"`);
3016
+ }
3017
+ function cleanupBrokenVenv() {
3018
+ try {
3019
+ if (existsSync4(SIDECAR_VENV_PATH2)) {
3020
+ rmSync(SIDECAR_VENV_PATH2, { recursive: true, force: true });
3021
+ }
3022
+ } catch {
3023
+ }
3024
+ }
3025
+ function resolveRequirementsTxt(baseDir) {
3026
+ const sidecarDir = resolveAssetDir(baseDir, "sidecar");
3027
+ return join6(sidecarDir, "requirements.txt");
3028
+ }
3029
+ async function installDeps(requirementsPath) {
3030
+ const pip = join6(SIDECAR_VENV_PATH2, VENV_BIN2, VENV_PIP_NAME);
3031
+ await exec(`"${pip}" install --upgrade pip`);
3032
+ const { stdout, stderr } = await exec(`"${pip}" install -r "${requirementsPath}"`);
3033
+ if (stdout) process.stdout.write(stdout);
3034
+ if (stderr) process.stderr.write(stderr);
3035
+ }
3036
+ async function downloadModelWeights() {
3037
+ const python = join6(SIDECAR_VENV_PATH2, VENV_BIN2, VENV_PYTHON_NAME2);
3038
+ const script = [
3039
+ "from huggingface_hub import snapshot_download",
3040
+ `print("Downloading ${MODEL_ID} weights to ~/.cache/huggingface/hub/ ...")`,
3041
+ `snapshot_download("${MODEL_ID}")`,
3042
+ `print("Download complete.")`
3043
+ ].join("; ");
3044
+ const { stdout, stderr } = await exec(`"${python}" -c "${script}"`);
3045
+ if (stdout) process.stdout.write(stdout);
3046
+ if (stderr) process.stderr.write(stderr);
3047
+ }
3048
+ function copySidecarScript(baseDir) {
3049
+ const sidecarDir = resolveAssetDir(baseDir, "sidecar");
3050
+ const src = join6(sidecarDir, "server.py");
3051
+ if (!existsSync4(src)) {
3052
+ throw new Error(
3053
+ `sidecar/server.py not found at: ${src}
3054
+ Run \`pnpm nx build reaper-mcp-server\` first.`
3055
+ );
3056
+ }
3057
+ mkdirSync3(SIDECAR_DIR, { recursive: true });
3058
+ copyFileSync2(src, SIDECAR_SCRIPT_DEST);
3059
+ }
3060
+ async function setupSidecar() {
3061
+ console.log("REAPER MCP \u2014 Setup Python Sidecar\n");
3062
+ console.log("This installs the opt-in audio AI subsystem for perceptual analysis.");
3063
+ console.log("Requires Python 3.10+ and an internet connection (~831 MB download).\n");
3064
+ const pythonBin = getPythonBin();
3065
+ console.log(`Checking Python interpreter: ${pythonBin}`);
3066
+ let version;
3067
+ try {
3068
+ version = await checkPython(pythonBin);
3069
+ } catch (err) {
3070
+ console.error(`
3071
+ Error: ${err instanceof Error ? err.message : String(err)}`);
3072
+ process.exit(1);
3073
+ }
3074
+ console.log(` Using Python interpreter: ${pythonBin} (${version})`);
3075
+ console.log(`
3076
+ Creating virtual environment at: ${SIDECAR_VENV_PATH2}`);
3077
+ try {
3078
+ await createVenv(pythonBin);
3079
+ console.log(" Virtual environment created.");
3080
+ } catch (err) {
3081
+ console.error(`
3082
+ Failed to create venv: ${err instanceof Error ? err.message : String(err)}`);
3083
+ process.exit(1);
3084
+ }
3085
+ const requirementsPath = resolveRequirementsTxt(__dirname);
3086
+ console.log(`
3087
+ Installing Python dependencies from: ${requirementsPath}`);
3088
+ if (!existsSync4(requirementsPath)) {
3089
+ console.error(`
3090
+ Error: requirements.txt not found at: ${requirementsPath}`);
3091
+ console.error("Run `pnpm nx build reaper-mcp-server` first.");
3092
+ process.exit(1);
3093
+ }
3094
+ try {
3095
+ await installDeps(requirementsPath);
3096
+ console.log(" Dependencies installed.");
3097
+ } catch (err) {
3098
+ console.error(`
3099
+ Failed to install dependencies: ${err instanceof Error ? err.message : String(err)}`);
3100
+ cleanupBrokenVenv();
3101
+ console.error("Removed partial venv so the next setup-sidecar run starts clean.");
3102
+ process.exit(1);
3103
+ }
3104
+ console.log("\nPre-downloading Audiobox Aesthetics model weights (~831 MB)...");
3105
+ console.log("This may take several minutes depending on your internet connection.");
3106
+ try {
3107
+ await downloadModelWeights();
3108
+ } catch (err) {
3109
+ console.error(`
3110
+ Model download failed: ${err instanceof Error ? err.message : String(err)}`);
3111
+ console.error("Check your internet connection and run setup-sidecar again.");
3112
+ process.exit(1);
3113
+ }
3114
+ console.log(`
3115
+ Installing sidecar script to: ${SIDECAR_SCRIPT_DEST}`);
3116
+ try {
3117
+ copySidecarScript(__dirname);
3118
+ console.log(" Sidecar script installed.");
3119
+ } catch (err) {
3120
+ console.error(`
3121
+ Failed to install sidecar script: ${err instanceof Error ? err.message : String(err)}`);
3122
+ process.exit(1);
3123
+ }
3124
+ console.log("\nSidecar setup complete!\n");
3125
+ console.log("The analyze_track_aesthetics tool is now available.");
3126
+ console.log("Run `node dist/apps/reaper-mcp-server/main.js doctor` to verify.\n");
3127
+ }
3128
+
3129
+ // apps/reaper-mcp-server/src/main.ts
3130
+ var execAsync = promisifyUtil(execCb);
3131
+ var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
2670
3132
  async function setup() {
2671
3133
  console.log("REAPER MCP Server \u2014 Setup\n");
2672
3134
  const bridgeDir = await ensureBridgeDir();
2673
3135
  console.log(`Bridge directory: ${bridgeDir}
2674
3136
  `);
2675
3137
  const scriptsDir = getReaperScriptsPath();
2676
- mkdirSync3(scriptsDir, { recursive: true });
2677
- const reaperDir = resolveAssetDir(__dirname, "reaper");
3138
+ mkdirSync4(scriptsDir, { recursive: true });
3139
+ const reaperDir = resolveAssetDir(__dirname2, "reaper");
2678
3140
  console.log("Installing Lua scripts...");
2679
3141
  for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
2680
- const src = join5(reaperDir, luaFile);
2681
- const dest = join5(scriptsDir, luaFile);
3142
+ const src = join7(reaperDir, luaFile);
3143
+ const dest = join7(scriptsDir, luaFile);
2682
3144
  if (installFile(src, dest)) {
2683
3145
  console.log(` Installed: ${luaFile}`);
2684
3146
  } else {
2685
3147
  console.log(` Not found: ${src}`);
2686
3148
  }
2687
3149
  }
2688
- const effectsDir = join5(getReaperEffectsPath(), "reaper-mcp");
2689
- mkdirSync3(effectsDir, { recursive: true });
3150
+ const effectsDir = join7(getReaperEffectsPath(), "reaper-mcp");
3151
+ mkdirSync4(effectsDir, { recursive: true });
2690
3152
  console.log("\nInstalling JSFX analyzers...");
2691
3153
  for (const asset of REAPER_ASSETS) {
2692
3154
  if (asset.endsWith(".lua")) continue;
2693
- const src = join5(reaperDir, asset);
2694
- const dest = join5(effectsDir, asset);
3155
+ const src = join7(reaperDir, asset);
3156
+ const dest = join7(effectsDir, asset);
2695
3157
  if (installFile(src, dest)) {
2696
3158
  console.log(` Installed: reaper-mcp/${asset}`);
2697
3159
  } else {
@@ -2702,7 +3164,7 @@ async function setup() {
2702
3164
  console.log("Next steps:");
2703
3165
  console.log(" 1. Open REAPER");
2704
3166
  console.log(" 2. Actions > Show action list > Load ReaScript");
2705
- console.log(` 3. Select: ${join5(scriptsDir, "mcp_bridge.lua")}`);
3167
+ console.log(` 3. Select: ${join7(scriptsDir, "mcp_bridge.lua")}`);
2706
3168
  console.log(" 4. Run the script (it will keep running in background via defer loop)");
2707
3169
  console.log(" 5. Add reaper-mcp to your Claude Code config (see: npx @mthines/reaper-mcp doctor)");
2708
3170
  }
@@ -2714,41 +3176,41 @@ async function installSkills(scope) {
2714
3176
  console.log(`REAPER MCP \u2014 Install AI Mix Engineer Skills (scope: ${scope})
2715
3177
  `);
2716
3178
  const isGlobal = scope === "global";
2717
- const baseDir = isGlobal ? join5(homedir3(), ".claude") : process.cwd();
2718
- const claudeDir = isGlobal ? baseDir : join5(baseDir, ".claude");
2719
- const knowledgeSrc = resolveAssetDir(__dirname, "knowledge");
2720
- if (existsSync3(knowledgeSrc)) {
2721
- const dest = join5(baseDir, "knowledge");
3179
+ const baseDir = isGlobal ? join7(homedir5(), ".claude") : process.cwd();
3180
+ const claudeDir = isGlobal ? baseDir : join7(baseDir, ".claude");
3181
+ const knowledgeSrc = resolveAssetDir(__dirname2, "knowledge");
3182
+ if (existsSync5(knowledgeSrc)) {
3183
+ const dest = join7(baseDir, "knowledge");
2722
3184
  const count = copyDirSync(knowledgeSrc, dest);
2723
3185
  console.log(`Installed knowledge base: ${count} files \u2192 ${dest}`);
2724
3186
  } else {
2725
3187
  console.log("Knowledge base not found in package. Skipping.");
2726
3188
  }
2727
- const rulesSrc = resolveAssetDirWithFallback(__dirname, "claude-rules", join5(".claude", "rules"));
2728
- if (existsSync3(rulesSrc)) {
2729
- const dest = join5(claudeDir, "rules");
3189
+ const rulesSrc = resolveAssetDirWithFallback(__dirname2, "claude-rules", join7(".claude", "rules"));
3190
+ if (existsSync5(rulesSrc)) {
3191
+ const dest = join7(claudeDir, "rules");
2730
3192
  const count = copyDirSync(rulesSrc, dest);
2731
3193
  console.log(`Installed Claude rules: ${count} files \u2192 ${dest}`);
2732
3194
  } else {
2733
3195
  console.log("Claude rules not found in package. Skipping.");
2734
3196
  }
2735
- const skillsSrc = resolveAssetDirWithFallback(__dirname, "claude-skills", join5(".claude", "skills"));
2736
- if (existsSync3(skillsSrc)) {
2737
- const dest = join5(claudeDir, "skills");
3197
+ const skillsSrc = resolveAssetDirWithFallback(__dirname2, "claude-skills", join7(".claude", "skills"));
3198
+ if (existsSync5(skillsSrc)) {
3199
+ const dest = join7(claudeDir, "skills");
2738
3200
  const count = copyDirSync(skillsSrc, dest);
2739
3201
  console.log(`Installed Claude skills: ${count} files \u2192 ${dest}`);
2740
3202
  } else {
2741
3203
  console.log("Claude skills not found in package. Skipping.");
2742
3204
  }
2743
- const agentsSrc = resolveAssetDirWithFallback(__dirname, "claude-agents", join5(".claude", "agents"));
2744
- if (existsSync3(agentsSrc)) {
2745
- const dest = join5(claudeDir, "agents");
3205
+ const agentsSrc = resolveAssetDirWithFallback(__dirname2, "claude-agents", join7(".claude", "agents"));
3206
+ if (existsSync5(agentsSrc)) {
3207
+ const dest = join7(claudeDir, "agents");
2746
3208
  const count = copyDirSync(agentsSrc, dest);
2747
3209
  console.log(`Installed Claude agents: ${count} files \u2192 ${dest}`);
2748
3210
  } else {
2749
3211
  console.log("Claude agents not found in package. Skipping.");
2750
3212
  }
2751
- const settingsPath = join5(claudeDir, "settings.json");
3213
+ const settingsPath = join7(claudeDir, "settings.json");
2752
3214
  const result = ensureClaudeSettings(settingsPath);
2753
3215
  if (result === "created") {
2754
3216
  console.log(`Created Claude settings: ${settingsPath}`);
@@ -2758,7 +3220,7 @@ async function installSkills(scope) {
2758
3220
  console.log(`Claude settings already has all REAPER tools: ${settingsPath}`);
2759
3221
  }
2760
3222
  if (!isGlobal) {
2761
- const mcpJsonPath = join5(baseDir, ".mcp.json");
3223
+ const mcpJsonPath = join7(baseDir, ".mcp.json");
2762
3224
  if (createMcpJson(mcpJsonPath)) {
2763
3225
  console.log(`
2764
3226
  Created: ${mcpJsonPath}`);
@@ -2779,31 +3241,92 @@ async function doctor() {
2779
3241
  if (!bridgeRunning) {
2780
3242
  console.log(' \u2192 Run "npx @mthines/reaper-mcp setup" then load mcp_bridge.lua in REAPER');
2781
3243
  }
2782
- const globalClaudeDir = join5(homedir3(), ".claude");
2783
- const localAgents = existsSync3(join5(process.cwd(), ".claude", "agents"));
2784
- const globalAgents = existsSync3(join5(globalClaudeDir, "agents"));
3244
+ const globalClaudeDir = join7(homedir5(), ".claude");
3245
+ const localAgents = existsSync5(join7(process.cwd(), ".claude", "agents"));
3246
+ const globalAgents = existsSync5(join7(globalClaudeDir, "agents"));
2785
3247
  const agentsExist = localAgents || globalAgents;
2786
3248
  const agentsLocation = localAgents ? ".claude/agents/" : globalAgents ? "~/.claude/agents/" : "";
2787
3249
  console.log(`Mix agents: ${agentsExist ? `\u2713 Found (${agentsLocation})` : "\u2717 Not installed"}`);
2788
3250
  if (!agentsExist) {
2789
3251
  console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills"');
2790
3252
  }
2791
- const localKnowledge = existsSync3(join5(process.cwd(), "knowledge"));
2792
- const globalKnowledge = existsSync3(join5(globalClaudeDir, "knowledge"));
3253
+ const localKnowledge = existsSync5(join7(process.cwd(), "knowledge"));
3254
+ const globalKnowledge = existsSync5(join7(globalClaudeDir, "knowledge"));
2793
3255
  const knowledgeExists = localKnowledge || globalKnowledge;
2794
3256
  const knowledgeLocation = localKnowledge ? "project" : globalKnowledge ? "~/.claude/" : "";
2795
3257
  console.log(`Knowledge base: ${knowledgeExists ? `\u2713 Found (${knowledgeLocation})` : "\u2717 Not installed"}`);
2796
3258
  if (!knowledgeExists) {
2797
3259
  console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills"');
2798
3260
  }
2799
- const mcpJsonExists = existsSync3(join5(process.cwd(), ".mcp.json"));
3261
+ const mcpJsonExists = existsSync5(join7(process.cwd(), ".mcp.json"));
2800
3262
  console.log(`MCP config: ${mcpJsonExists ? "\u2713 .mcp.json found" : "\u2717 .mcp.json missing"}`);
2801
3263
  if (!mcpJsonExists) {
2802
3264
  console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills --project" to create .mcp.json');
2803
3265
  }
2804
3266
  console.log('\nTo check SWS Extensions, start REAPER and use the "list_available_fx" tool.');
2805
3267
  console.log("SWS provides enhanced plugin discovery and snapshot support.\n");
2806
- process.exit(bridgeRunning && knowledgeExists && mcpJsonExists ? 0 : 1);
3268
+ console.log("Python Sidecar (opt-in, for analyze_track_aesthetics):");
3269
+ const sidecarVenvPath = join7(homedir5(), ".reaper-mcp", "sidecar-venv");
3270
+ const venvBin = platform4() === "win32" ? "Scripts" : "bin";
3271
+ const venvPythonName = platform4() === "win32" ? "python.exe" : "python";
3272
+ const venvPython = join7(sidecarVenvPath, venvBin, venvPythonName);
3273
+ const hfCachePath = join7(homedir5(), ".cache", "huggingface", "hub");
3274
+ let pythonOk = false;
3275
+ let pythonDetail = "not found";
3276
+ try {
3277
+ const pythonBin = process.env["PYTHON_BIN"] ?? "python3";
3278
+ const { stdout, stderr } = await execAsync(`"${pythonBin}" --version`);
3279
+ const versionStr = (stdout + stderr).trim();
3280
+ const match = versionStr.match(/Python\s+(\d+)\.(\d+)/i);
3281
+ if (match) {
3282
+ const major = parseInt(match[1], 10);
3283
+ const minor = parseInt(match[2], 10);
3284
+ pythonOk = major > 3 || major === 3 && minor >= 10;
3285
+ pythonDetail = versionStr;
3286
+ }
3287
+ } catch {
3288
+ pythonDetail = "not found";
3289
+ }
3290
+ console.log(` Python \u2265 3.10: ${pythonOk ? `\u2713 ${pythonDetail}` : `\u2717 ${pythonDetail}`}`);
3291
+ if (!pythonOk) {
3292
+ console.log(" \u2192 Install Python 3.10+ from https://python.org or set PYTHON_BIN");
3293
+ }
3294
+ const venvExists = existsSync5(sidecarVenvPath);
3295
+ console.log(` Venv: ${venvExists ? `\u2713 ${sidecarVenvPath}` : "\u2717 Not installed"}`);
3296
+ if (!venvExists) {
3297
+ console.log(" \u2192 Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar");
3298
+ }
3299
+ let depsOk = false;
3300
+ if (venvExists && existsSync5(venvPython)) {
3301
+ try {
3302
+ await execAsync(`"${venvPython}" -c "import audiobox_aesthetics"`);
3303
+ depsOk = true;
3304
+ } catch {
3305
+ depsOk = false;
3306
+ }
3307
+ }
3308
+ console.log(` Dependencies: ${depsOk ? "\u2713 audiobox-aesthetics importable" : "\u2717 Not installed"}`);
3309
+ if (!depsOk && venvExists) {
3310
+ console.log(" \u2192 Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar");
3311
+ }
3312
+ const weightsPath = join7(hfCachePath, "models--facebook--audiobox-aesthetics");
3313
+ const weightsExist = existsSync5(weightsPath);
3314
+ console.log(` Model weights: ${weightsExist ? `\u2713 ${weightsPath}` : "\u2717 Not downloaded"}`);
3315
+ if (!weightsExist) {
3316
+ console.log(" \u2192 Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar");
3317
+ }
3318
+ const sidecarReady = venvExists && depsOk && weightsExist;
3319
+ const sidecarOptedIn = venvExists || weightsExist;
3320
+ const sidecarBroken = sidecarOptedIn && !sidecarReady;
3321
+ if (!sidecarOptedIn) {
3322
+ console.log("\n Sidecar: not installed (opt-in). Run setup-sidecar to enable audio AI tools.");
3323
+ } else if (sidecarBroken) {
3324
+ console.log("\n Sidecar: PARTIALLY INSTALLED \u2014 run setup-sidecar to repair.");
3325
+ } else {
3326
+ console.log("\n Sidecar: fully installed and ready.");
3327
+ }
3328
+ console.log("");
3329
+ process.exit(bridgeRunning && knowledgeExists && mcpJsonExists && !sidecarBroken ? 0 : 1);
2807
3330
  }
2808
3331
  async function serve() {
2809
3332
  const log = (...args) => console.error("[reaper-mcp]", ...args);
@@ -2812,7 +3335,7 @@ async function serve() {
2812
3335
  startDiagnosticsPoller();
2813
3336
  startEventsPoller();
2814
3337
  log("Starting REAPER MCP Server...");
2815
- log(`Entry: ${fileURLToPath2(import.meta.url)}`);
3338
+ log(`Entry: ${fileURLToPath3(import.meta.url)}`);
2816
3339
  const tracer = getTracer();
2817
3340
  await tracer.startActiveSpan("mcp.server.startup", { kind: SpanKind3.INTERNAL }, async (startupSpan) => {
2818
3341
  try {
@@ -2866,7 +3389,7 @@ switch (command) {
2866
3389
  case "init":
2867
3390
  runInit(
2868
3391
  { yes: hasYesFlag, project: hasProjectFlag },
2869
- () => __dirname
3392
+ () => __dirname2
2870
3393
  ).catch((err) => {
2871
3394
  console.error("Init failed:", err);
2872
3395
  process.exit(1);
@@ -2890,6 +3413,12 @@ switch (command) {
2890
3413
  process.exit(1);
2891
3414
  });
2892
3415
  break;
3416
+ case "setup-sidecar":
3417
+ setupSidecar().catch((err) => {
3418
+ console.error("Sidecar setup failed:", err);
3419
+ process.exit(1);
3420
+ });
3421
+ break;
2893
3422
  case "status": {
2894
3423
  (async () => {
2895
3424
  const running = await isBridgeRunning();
@@ -2915,6 +3444,7 @@ Usage:
2915
3444
  npx @mthines/reaper-mcp init --yes Non-interactive setup (install everything with defaults)
2916
3445
  npx @mthines/reaper-mcp init --project Include .mcp.json in current directory
2917
3446
  npx @mthines/reaper-mcp setup Install Lua bridge + JSFX analyzers into REAPER
3447
+ npx @mthines/reaper-mcp setup-sidecar Install Python sidecar for perceptual audio analysis (opt-in, ~831 MB)
2918
3448
  npx @mthines/reaper-mcp install-skills Install AI knowledge + agents globally (default)
2919
3449
  npx @mthines/reaper-mcp install-skills --project Install into current project directory
2920
3450
  npx @mthines/reaper-mcp install-skills --global Install into ~/.claude/ (default)
@@ -2936,24 +3466,31 @@ Tip: install globally for shorter commands:
2936
3466
  `);
2937
3467
  break;
2938
3468
  }
3469
+ function shutdownAll() {
3470
+ stopPollers();
3471
+ try {
3472
+ getSidecarClient().shutdown();
3473
+ } catch {
3474
+ }
3475
+ }
2939
3476
  process.on("SIGINT", () => {
2940
3477
  console.error("[reaper-mcp] Interrupted");
2941
- stopPollers();
3478
+ shutdownAll();
2942
3479
  shutdownTelemetry().finally(() => process.exit(0));
2943
3480
  });
2944
3481
  process.on("SIGTERM", () => {
2945
3482
  console.error("[reaper-mcp] Terminated");
2946
- stopPollers();
3483
+ shutdownAll();
2947
3484
  shutdownTelemetry().finally(() => process.exit(0));
2948
3485
  });
2949
3486
  process.on("uncaughtException", (err) => {
2950
3487
  console.error("[reaper-mcp] Uncaught exception:", err);
2951
- stopPollers();
3488
+ shutdownAll();
2952
3489
  shutdownTelemetry().finally(() => process.exit(1));
2953
3490
  });
2954
3491
  process.on("unhandledRejection", (reason) => {
2955
3492
  console.error("[reaper-mcp] Unhandled rejection:", reason);
2956
- stopPollers();
3493
+ shutdownAll();
2957
3494
  shutdownTelemetry().finally(() => process.exit(1));
2958
3495
  });
2959
3496
  //# sourceMappingURL=main.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.19.0-beta.20.1",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for controlling REAPER DAW — real-time mixing, FX control, and frequency analysis for AI agents",
6
6
  "license": "MIT",
@@ -1533,7 +1533,11 @@ local MCP_LUFS_METER_FX_NAME = "reaper-mcp/mcp_lufs_meter"
1533
1533
  local MCP_CORRELATION_METER_FX_NAME = "reaper-mcp/mcp_correlation_meter"
1534
1534
  local MCP_CREST_FACTOR_FX_NAME = "reaper-mcp/mcp_crest_factor"
1535
1535
  local MCP_MIDI_EMITTER_FX_NAME = "reaper-mcp/mcp_midi_emitter"
1536
- local MCP_FX_PREFIX = "reaper%-mcp/" -- Lua pattern for matching MCP JSFX names
1536
+ -- Lua pattern matching the JSFX `desc:` line — that's what TrackFX_GetFXName
1537
+ -- returns, not the on-disk path. Every MCP JSFX starts its desc with "MCP ".
1538
+ -- (The outer container is renamed to "MCP Meters", but this prefix is only used
1539
+ -- to identify the JSFX *inside* a container, so collisions are not possible.)
1540
+ local MCP_FX_PREFIX = "MCP "
1537
1541
 
1538
1542
  -- Per-track cache of MCP container FX index to avoid rescanning on every read.
1539
1543
  -- Keyed by track pointer (userdata), value is container FX index or false (no container).
@@ -3612,6 +3616,187 @@ function handlers.send_midi_note(params)
3612
3616
  return { sent = true, timestampMs = math.floor(reaper.time_precise() * 1000) }
3613
3617
  end
3614
3618
 
3619
+ -- =============================================================================
3620
+ -- render_track_to_wav handler
3621
+ --
3622
+ -- Renders a target track's output (post-FX, post-fader) to a temp WAV file.
3623
+ -- Saves and restores all render settings and solo states around the render.
3624
+ -- Blocks the defer loop during render (~0.5-1s) — this is by design.
3625
+ --
3626
+ -- Params:
3627
+ -- trackIndex (number) — zero-based track index
3628
+ -- startTime (number) — render start, seconds from project start
3629
+ -- endTime (number) — render end, seconds from project start
3630
+ -- commandId (string) — used to generate a unique temp filename
3631
+ --
3632
+ -- Returns:
3633
+ -- { trackIndex, trackName, wavPath, durationSeconds, sampleRate, channelCount }
3634
+ -- =============================================================================
3635
+
3636
+ function handlers.render_track_to_wav(params)
3637
+ local track_index = params.trackIndex
3638
+ local start_time = params.startTime
3639
+ local end_time = params.endTime
3640
+ local command_id = params.commandId or tostring(os.time())
3641
+
3642
+ -- Validate inputs
3643
+ if track_index == nil then
3644
+ return nil, "Missing required param: trackIndex"
3645
+ end
3646
+ if start_time == nil or end_time == nil then
3647
+ return nil, "Missing required params: startTime, endTime"
3648
+ end
3649
+ if end_time <= start_time then
3650
+ return nil, "endTime must be greater than startTime"
3651
+ end
3652
+
3653
+ -- Get track
3654
+ local track = reaper.GetTrack(0, track_index)
3655
+ if not track then
3656
+ return nil, "Track " .. tostring(track_index) .. " not found"
3657
+ end
3658
+ local track_name = ({reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "", false)})[2] or ""
3659
+ if track_name == "" then track_name = "Track " .. (track_index + 1) end
3660
+
3661
+ -- Render target is always 44100 Hz (WAV for sidecar inference).
3662
+ -- The actual project sample rate is irrelevant here; Audiobox resamples internally.
3663
+ local sample_rate = 44100
3664
+
3665
+ -- Build unique temp file path
3666
+ -- Use GetTempPath() if available (REAPER 6.29+), fall back to os.tmpname pattern
3667
+ local temp_dir
3668
+ if reaper.GetTempPath then
3669
+ temp_dir = reaper.GetTempPath()
3670
+ else
3671
+ -- Fallback: derive temp dir from os.tmpname()
3672
+ local t = os.tmpname()
3673
+ temp_dir = t:match("^(.+)[/\\][^/\\]+$") or "/tmp"
3674
+ os.remove(t)
3675
+ end
3676
+
3677
+ -- Ensure temp dir ends with separator
3678
+ if temp_dir:sub(-1) ~= "/" and temp_dir:sub(-1) ~= "\\" then
3679
+ temp_dir = temp_dir .. "/"
3680
+ end
3681
+
3682
+ local wav_filename = "mcp_aes_" .. command_id:gsub("-", ""):sub(1, 16) .. ".wav"
3683
+ local wav_path = temp_dir .. wav_filename
3684
+
3685
+ -- -------------------------------------------------------------------------
3686
+ -- Save current render settings
3687
+ -- -------------------------------------------------------------------------
3688
+ local saved_file = ({reaper.GetSetProjectInfo_String(0, "RENDER_FILE", "", false)})[2] or ""
3689
+ local saved_pattern = ({reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", "", false)})[2] or ""
3690
+ local saved_format = ({reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "", false)})[2] or ""
3691
+ local saved_bounds = reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, false)
3692
+ local saved_settings = reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 0, false)
3693
+ local saved_start = reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", 0, false)
3694
+ local saved_end = reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", 0, false)
3695
+ local saved_srate = reaper.GetSetProjectInfo(0, "RENDER_SRATE", 0, false)
3696
+ local saved_channels = reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 0, false)
3697
+
3698
+ -- Save solo states for all tracks
3699
+ local track_count = reaper.CountTracks(0)
3700
+ local saved_solo_states = {}
3701
+ for i = 0, track_count - 1 do
3702
+ local t = reaper.GetTrack(0, i)
3703
+ saved_solo_states[i] = reaper.GetMediaTrackInfo_Value(t, "I_SOLO")
3704
+ end
3705
+
3706
+ -- -------------------------------------------------------------------------
3707
+ -- Apply render settings
3708
+ -- -------------------------------------------------------------------------
3709
+ local ok, restore_err = pcall(function()
3710
+ -- Set output: render to temp dir with unique filename (no extension in pattern)
3711
+ reaper.GetSetProjectInfo_String(0, "RENDER_FILE", temp_dir, true)
3712
+ reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", wav_filename:match("^(.+)%.wav$") or wav_filename, true)
3713
+
3714
+ -- Set render bounds: 0 = custom time range
3715
+ reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, true)
3716
+ reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", start_time, true)
3717
+ reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", end_time, true)
3718
+
3719
+ -- Set render mode: 2 = stems (selected tracks via master mix / solo)
3720
+ reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 2, true)
3721
+
3722
+ -- Force WAV at 44100 Hz stereo so the sidecar always gets a known format,
3723
+ -- independent of whatever the user last rendered. Restored in finally.
3724
+ -- "evaw" is the little-endian "wave" fourcc REAPER uses for WAV format.
3725
+ reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "evaw", true)
3726
+ reaper.GetSetProjectInfo(0, "RENDER_SRATE", sample_rate, true)
3727
+ reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 2, true)
3728
+
3729
+ -- Unsolo all tracks, then solo only the target track
3730
+ for i = 0, track_count - 1 do
3731
+ local t = reaper.GetTrack(0, i)
3732
+ reaper.SetMediaTrackInfo_Value(t, "I_SOLO", 0)
3733
+ end
3734
+ reaper.SetMediaTrackInfo_Value(track, "I_SOLO", 1)
3735
+
3736
+ -- Trigger render: action 42230 = "Render project, using the most recent render settings, auto-close render dialog"
3737
+ -- This call is synchronous — it blocks the defer loop until rendering completes.
3738
+ reaper.Main_OnCommand(42230, 0)
3739
+ end)
3740
+
3741
+ -- -------------------------------------------------------------------------
3742
+ -- Restore all settings and solo states (always, even on error)
3743
+ -- -------------------------------------------------------------------------
3744
+ local restore_ok, restore_err2 = pcall(function()
3745
+ reaper.GetSetProjectInfo_String(0, "RENDER_FILE", saved_file, true)
3746
+ reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", saved_pattern, true)
3747
+ reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", saved_format, true)
3748
+ reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", saved_bounds, true)
3749
+ reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", saved_settings, true)
3750
+ reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", saved_start, true)
3751
+ reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", saved_end, true)
3752
+ reaper.GetSetProjectInfo(0, "RENDER_SRATE", saved_srate, true)
3753
+ reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", saved_channels, true)
3754
+ for i = 0, track_count - 1 do
3755
+ local t = reaper.GetTrack(0, i)
3756
+ reaper.SetMediaTrackInfo_Value(t, "I_SOLO", saved_solo_states[i] or 0)
3757
+ end
3758
+ end)
3759
+
3760
+ if not restore_ok then
3761
+ -- Log but don't fail — the render may have succeeded
3762
+ reaper.ShowConsoleMsg("[reaper-mcp] Warning: failed to restore render settings: " .. tostring(restore_err2) .. "\n")
3763
+ end
3764
+
3765
+ if not ok then
3766
+ return nil, "Render failed: " .. tostring(restore_err)
3767
+ end
3768
+
3769
+ -- Verify the file was actually created.
3770
+ -- RENDER_PATTERN was set without the .wav extension so REAPER appends it;
3771
+ -- wav_path already has the full expected name including the extension.
3772
+ local f = io.open(wav_path, "rb")
3773
+ if not f then
3774
+ return nil, "Render produced no output file at: " .. wav_path
3775
+ end
3776
+ -- Verify the rendered file is actually a WAV. RENDER_FORMAT was set to "evaw"
3777
+ -- (REAPER's WAV fourcc) above; if REAPER silently ignored that value the file
3778
+ -- could be any codec the user last rendered (MP3/FLAC/etc.). Check the RIFF
3779
+ -- magic bytes so we fail loudly here rather than crashing in the Python sidecar
3780
+ -- with a confusing "Cannot read audio file" error.
3781
+ local magic = f:read(4)
3782
+ f:close()
3783
+ if magic ~= "RIFF" then
3784
+ os.remove(wav_path)
3785
+ return nil, "Render produced a non-WAV file (RENDER_FORMAT may not have been applied). " ..
3786
+ "First 4 bytes: " .. (magic and string.format("%q", magic) or "<empty>")
3787
+ end
3788
+
3789
+ local duration = end_time - start_time
3790
+ return {
3791
+ trackIndex = track_index,
3792
+ trackName = track_name,
3793
+ wavPath = wav_path,
3794
+ durationSeconds = duration,
3795
+ sampleRate = sample_rate,
3796
+ channelCount = 2,
3797
+ }
3798
+ end
3799
+
3615
3800
  -- =============================================================================
3616
3801
  -- Bridge diagnostics handler
3617
3802
  -- =============================================================================