@mthines/reaper-mcp 0.18.0 → 0.19.0-beta.19.2

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 {
@@ -2179,6 +2179,288 @@ function registerCategoryTools(server) {
2179
2179
  );
2180
2180
  }
2181
2181
 
2182
+ // apps/reaper-mcp-server/src/tools/aesthetics.ts
2183
+ import { z as z17 } from "zod/v4";
2184
+ import { unlink as unlink2 } from "node:fs/promises";
2185
+ import { randomUUID as randomUUID2 } from "node:crypto";
2186
+
2187
+ // apps/reaper-mcp-server/src/sidecar.ts
2188
+ import { spawn } from "node:child_process";
2189
+ import { existsSync } from "node:fs";
2190
+ import { join as join3 } from "node:path";
2191
+ import { homedir as homedir2, platform as platform2 } from "node:os";
2192
+ var VENV_BIN = platform2() === "win32" ? "Scripts" : "bin";
2193
+ var VENV_PYTHON_NAME = platform2() === "win32" ? "python.exe" : "python";
2194
+ var SIDECAR_VENV_PATH = join3(homedir2(), ".reaper-mcp", "sidecar-venv");
2195
+ var SIDECAR_SCRIPT_PATH = join3(homedir2(), ".reaper-mcp", "sidecar", "server.py");
2196
+ var VENV_PYTHON = join3(SIDECAR_VENV_PATH, VENV_BIN, VENV_PYTHON_NAME);
2197
+ var _rawTimeout = Number(process.env["REAPER_MCP_SIDECAR_TIMEOUT_MS"]);
2198
+ var ANALYZE_TIMEOUT_MS = Number.isFinite(_rawTimeout) && _rawTimeout > 0 ? _rawTimeout : 6e4;
2199
+ var SidecarClientImpl = class {
2200
+ process = null;
2201
+ pending = /* @__PURE__ */ new Map();
2202
+ nextId = 1;
2203
+ lineBuffer = "";
2204
+ restarting = false;
2205
+ restartAttempted = false;
2206
+ /** Cached spawn promise — prevents double-spawn on concurrent cold-start calls. */
2207
+ spawnPromise = null;
2208
+ isAvailable() {
2209
+ return existsSync(SIDECAR_SCRIPT_PATH) && existsSync(VENV_PYTHON);
2210
+ }
2211
+ async analyze(wavPath, startTime, endTime) {
2212
+ if (!this.isAvailable()) {
2213
+ throw new Error(
2214
+ "Audio understanding sidecar not installed. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar"
2215
+ );
2216
+ }
2217
+ await this.ensureRunning();
2218
+ return new Promise((resolve, reject) => {
2219
+ const id = this.nextId++;
2220
+ const request = {
2221
+ jsonrpc: "2.0",
2222
+ id,
2223
+ method: "analyze",
2224
+ params: { path: wavPath, startTime, endTime }
2225
+ };
2226
+ const timer = setTimeout(() => {
2227
+ if (this.pending.delete(id)) {
2228
+ process.stderr.write(
2229
+ `[reaper-mcp] Sidecar analyze() timed out after ${ANALYZE_TIMEOUT_MS}ms \u2014 killing process
2230
+ `
2231
+ );
2232
+ if (this.process) {
2233
+ this.process.kill("SIGKILL");
2234
+ this.process = null;
2235
+ }
2236
+ reject(new Error(
2237
+ `Sidecar timeout after ${ANALYZE_TIMEOUT_MS}ms. Override with REAPER_MCP_SIDECAR_TIMEOUT_MS env var.`
2238
+ ));
2239
+ }
2240
+ }, ANALYZE_TIMEOUT_MS);
2241
+ this.pending.set(id, {
2242
+ resolve: (r) => {
2243
+ clearTimeout(timer);
2244
+ resolve(r);
2245
+ },
2246
+ reject: (e) => {
2247
+ clearTimeout(timer);
2248
+ reject(e);
2249
+ }
2250
+ });
2251
+ const line = JSON.stringify(request) + "\n";
2252
+ try {
2253
+ const proc = this.process;
2254
+ if (!proc || !proc.stdin) {
2255
+ throw new Error("Sidecar process not running");
2256
+ }
2257
+ proc.stdin.write(line);
2258
+ } catch (err) {
2259
+ clearTimeout(timer);
2260
+ this.pending.delete(id);
2261
+ reject(new Error(`Failed to write to sidecar stdin: ${err instanceof Error ? err.message : String(err)}`));
2262
+ }
2263
+ });
2264
+ }
2265
+ shutdown() {
2266
+ if (this.process) {
2267
+ this.process.kill("SIGTERM");
2268
+ this.process = null;
2269
+ }
2270
+ for (const [id, { reject }] of this.pending.entries()) {
2271
+ reject(new Error("Sidecar shut down"));
2272
+ this.pending.delete(id);
2273
+ }
2274
+ }
2275
+ // -------------------------------------------------------------------------
2276
+ // Private
2277
+ // -------------------------------------------------------------------------
2278
+ async ensureRunning() {
2279
+ if (this.process && !this.process.killed) return;
2280
+ if (!this.spawnPromise) {
2281
+ this.spawnPromise = this.spawn().finally(() => {
2282
+ this.spawnPromise = null;
2283
+ });
2284
+ }
2285
+ await this.spawnPromise;
2286
+ }
2287
+ async spawn() {
2288
+ const child = spawn(VENV_PYTHON, [SIDECAR_SCRIPT_PATH], {
2289
+ stdio: ["pipe", "pipe", "pipe"]
2290
+ });
2291
+ if (child.stdout) {
2292
+ child.stdout.setEncoding("utf8");
2293
+ child.stdout.on("data", (chunk) => this.onStdout(chunk));
2294
+ }
2295
+ if (child.stderr) {
2296
+ child.stderr.setEncoding("utf8");
2297
+ child.stderr.on("data", (data) => {
2298
+ process.stderr.write(`[reaper-mcp-sidecar] ${data}`);
2299
+ });
2300
+ }
2301
+ child.on("exit", (code) => {
2302
+ process.stderr.write(`[reaper-mcp] Sidecar process exited (code ${code})
2303
+ `);
2304
+ this.process = null;
2305
+ this.handleProcessDeath();
2306
+ });
2307
+ child.on("error", (err) => {
2308
+ process.stderr.write(`[reaper-mcp] Sidecar spawn error: ${err.message}
2309
+ `);
2310
+ this.process = null;
2311
+ this.handleProcessDeath();
2312
+ });
2313
+ this.process = child;
2314
+ this.lineBuffer = "";
2315
+ this.restarting = false;
2316
+ this.restartAttempted = false;
2317
+ }
2318
+ onStdout(chunk) {
2319
+ this.lineBuffer += chunk;
2320
+ let newlineIdx;
2321
+ while ((newlineIdx = this.lineBuffer.indexOf("\n")) !== -1) {
2322
+ const line = this.lineBuffer.slice(0, newlineIdx).trim();
2323
+ this.lineBuffer = this.lineBuffer.slice(newlineIdx + 1);
2324
+ if (line) this.onResponse(line);
2325
+ }
2326
+ }
2327
+ onResponse(line) {
2328
+ let response;
2329
+ try {
2330
+ response = JSON.parse(line);
2331
+ } catch {
2332
+ process.stderr.write(`[reaper-mcp] Sidecar returned malformed JSON: ${line}
2333
+ `);
2334
+ for (const [id, { reject }] of this.pending.entries()) {
2335
+ reject(new Error(`Sidecar returned malformed JSON: ${line}`));
2336
+ this.pending.delete(id);
2337
+ }
2338
+ return;
2339
+ }
2340
+ const pending = this.pending.get(response.id);
2341
+ if (!pending) {
2342
+ process.stderr.write(`[reaper-mcp] Sidecar response for unknown id ${response.id}
2343
+ `);
2344
+ return;
2345
+ }
2346
+ this.pending.delete(response.id);
2347
+ if (response.error) {
2348
+ pending.reject(new Error(response.error.message));
2349
+ } else if (response.result) {
2350
+ pending.resolve(response.result);
2351
+ } else {
2352
+ pending.reject(new Error("Sidecar response missing both result and error fields"));
2353
+ }
2354
+ }
2355
+ handleProcessDeath() {
2356
+ if (this.restarting || this.pending.size === 0) {
2357
+ return;
2358
+ }
2359
+ if (!this.restartAttempted) {
2360
+ this.restarting = true;
2361
+ this.restartAttempted = true;
2362
+ process.stderr.write("[reaper-mcp] Sidecar crashed; attempting one restart...\n");
2363
+ this.spawn().then(() => {
2364
+ for (const [id, { reject }] of this.pending.entries()) {
2365
+ reject(new Error("Sidecar restarted; please retry the operation"));
2366
+ this.pending.delete(id);
2367
+ }
2368
+ }).catch((err) => {
2369
+ for (const [id, { reject }] of this.pending.entries()) {
2370
+ reject(new Error(`Sidecar restart failed: ${err.message}. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar`));
2371
+ this.pending.delete(id);
2372
+ }
2373
+ });
2374
+ } else {
2375
+ for (const [id, { reject }] of this.pending.entries()) {
2376
+ reject(new Error("Python sidecar unavailable after restart attempt. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar"));
2377
+ this.pending.delete(id);
2378
+ }
2379
+ }
2380
+ }
2381
+ };
2382
+ var _singleton = null;
2383
+ function getSidecarClient() {
2384
+ if (!_singleton) {
2385
+ _singleton = new SidecarClientImpl();
2386
+ }
2387
+ return _singleton;
2388
+ }
2389
+
2390
+ // apps/reaper-mcp-server/src/tools/aesthetics.ts
2391
+ var SIDECAR_NOT_INSTALLED_MSG = "Audio understanding sidecar not installed. Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar";
2392
+ function registerAestheticsTools(server) {
2393
+ server.tool(
2394
+ "analyze_track_aesthetics",
2395
+ "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).",
2396
+ {
2397
+ trackIndex: z17.coerce.number().int().min(0).describe("Zero-based track index"),
2398
+ startTime: z17.coerce.number().min(0).optional().describe("Start time in seconds (default: 0 or use REAPER time selection)"),
2399
+ endTime: z17.coerce.number().min(0).optional().describe("End time in seconds (default: startTime + durationSeconds)"),
2400
+ 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.")
2401
+ },
2402
+ async ({ trackIndex, startTime, endTime, durationSeconds }) => {
2403
+ const sidecar = getSidecarClient();
2404
+ if (!sidecar.isAvailable()) {
2405
+ return {
2406
+ content: [{ type: "text", text: SIDECAR_NOT_INSTALLED_MSG }],
2407
+ isError: true
2408
+ };
2409
+ }
2410
+ const resolvedStart = startTime ?? 0;
2411
+ const resolvedEnd = endTime ?? resolvedStart + (durationSeconds ?? 5);
2412
+ if (resolvedEnd <= resolvedStart) {
2413
+ return {
2414
+ content: [{ type: "text", text: "endTime must be greater than startTime" }],
2415
+ isError: true
2416
+ };
2417
+ }
2418
+ const commandId = randomUUID2();
2419
+ const renderRes = await sendCommand("render_track_to_wav", {
2420
+ trackIndex,
2421
+ startTime: resolvedStart,
2422
+ endTime: resolvedEnd,
2423
+ commandId
2424
+ });
2425
+ if (!renderRes.success) {
2426
+ return {
2427
+ content: [{ type: "text", text: `Render failed: ${renderRes.error}` }],
2428
+ isError: true
2429
+ };
2430
+ }
2431
+ const renderData = renderRes.data;
2432
+ const wavPath = renderData.wavPath;
2433
+ try {
2434
+ const scores = await sidecar.analyze(wavPath, resolvedStart, resolvedEnd);
2435
+ const result = {
2436
+ trackIndex,
2437
+ trackName: renderData.trackName,
2438
+ productionQuality: scores.PQ,
2439
+ productionComplexity: scores.PC,
2440
+ contentEnjoyment: scores.CE,
2441
+ contentUsefulness: scores.CU,
2442
+ startTime: resolvedStart,
2443
+ endTime: resolvedEnd,
2444
+ durationSeconds: resolvedEnd - resolvedStart,
2445
+ modelVersion: scores.modelVersion
2446
+ };
2447
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2448
+ } catch (err) {
2449
+ return {
2450
+ content: [{
2451
+ type: "text",
2452
+ text: err instanceof Error ? err.message : String(err)
2453
+ }],
2454
+ isError: true
2455
+ };
2456
+ } finally {
2457
+ await unlink2(wavPath).catch(() => {
2458
+ });
2459
+ }
2460
+ }
2461
+ );
2462
+ }
2463
+
2182
2464
  // apps/reaper-mcp-server/src/server.ts
2183
2465
  function instrumentToolHandlers(server) {
2184
2466
  const originalTool = server.tool.bind(server);
@@ -2238,40 +2520,43 @@ function createServer() {
2238
2520
  registerEnvelopeTools(server);
2239
2521
  registerBatchTools(server);
2240
2522
  registerCategoryTools(server);
2523
+ registerAestheticsTools(server);
2241
2524
  return server;
2242
2525
  }
2243
2526
 
2244
2527
  // apps/reaper-mcp-server/src/main.ts
2245
- import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
2246
- import { join as join5, dirname as dirname2 } from "node:path";
2247
- import { fileURLToPath as fileURLToPath2 } from "node:url";
2248
- import { homedir as homedir3 } from "node:os";
2528
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4 } from "node:fs";
2529
+ import { join as join7, dirname as dirname3 } from "node:path";
2530
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
2531
+ import { homedir as homedir5, platform as platform4 } from "node:os";
2532
+ import { exec as execCb } from "node:child_process";
2533
+ import { promisify as promisifyUtil } from "node:util";
2249
2534
 
2250
2535
  // apps/reaper-mcp-server/src/cli.ts
2251
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2252
- import { join as join3 } from "node:path";
2536
+ import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2537
+ import { join as join4 } from "node:path";
2253
2538
  function resolveAssetDir(baseDir, name) {
2254
- const sibling = join3(baseDir, name);
2255
- if (existsSync(sibling)) return sibling;
2256
- const parent = join3(baseDir, "..", name);
2257
- if (existsSync(parent)) return parent;
2539
+ const sibling = join4(baseDir, name);
2540
+ if (existsSync2(sibling)) return sibling;
2541
+ const parent = join4(baseDir, "..", name);
2542
+ if (existsSync2(parent)) return parent;
2258
2543
  let dir = baseDir;
2259
2544
  for (let i = 0; i < 5; i++) {
2260
- const candidate = join3(dir, name);
2261
- if (existsSync(candidate)) return candidate;
2262
- const up = join3(dir, "..");
2545
+ const candidate = join4(dir, name);
2546
+ if (existsSync2(candidate)) return candidate;
2547
+ const up = join4(dir, "..");
2263
2548
  if (up === dir) break;
2264
2549
  dir = up;
2265
2550
  }
2266
2551
  return sibling;
2267
2552
  }
2268
2553
  function copyDirSync(src, dest) {
2269
- if (!existsSync(src)) return 0;
2554
+ if (!existsSync2(src)) return 0;
2270
2555
  mkdirSync(dest, { recursive: true });
2271
2556
  let count = 0;
2272
2557
  for (const entry of readdirSync(src)) {
2273
- const srcPath = join3(src, entry);
2274
- const destPath = join3(dest, entry);
2558
+ const srcPath = join4(src, entry);
2559
+ const destPath = join4(dest, entry);
2275
2560
  if (statSync(srcPath).isDirectory()) {
2276
2561
  count += copyDirSync(srcPath, destPath);
2277
2562
  } else {
@@ -2282,14 +2567,14 @@ function copyDirSync(src, dest) {
2282
2567
  return count;
2283
2568
  }
2284
2569
  function installFile(src, dest) {
2285
- if (existsSync(src)) {
2570
+ if (existsSync2(src)) {
2286
2571
  copyFileSync(src, dest);
2287
2572
  return true;
2288
2573
  }
2289
2574
  return false;
2290
2575
  }
2291
2576
  function createMcpJson(targetPath) {
2292
- if (existsSync(targetPath)) return false;
2577
+ if (existsSync2(targetPath)) return false;
2293
2578
  const config = JSON.stringify({
2294
2579
  mcpServers: {
2295
2580
  reaper: {
@@ -2412,12 +2697,16 @@ var MCP_TOOL_NAMES = [
2412
2697
  // progressive discovery
2413
2698
  "list_tool_categories",
2414
2699
  "enable_tool_category",
2415
- "disable_tool_category"
2700
+ "disable_tool_category",
2701
+ // semantic audio analysis (requires Python sidecar — opt-in)
2702
+ "analyze_track_aesthetics"
2703
+ // Note: 'render_track_to_wav' is an internal Lua bridge command, NOT a public MCP tool.
2704
+ // It must NOT be listed here — MCP_TOOL_NAMES drives the Claude Code allowlist.
2416
2705
  ];
2417
2706
  function ensureClaudeSettings(settingsPath) {
2418
2707
  const allowList = MCP_TOOL_NAMES.map((t) => `mcp__reaper__${t}`);
2419
- if (!existsSync(settingsPath)) {
2420
- mkdirSync(join3(settingsPath, ".."), { recursive: true });
2708
+ if (!existsSync2(settingsPath)) {
2709
+ mkdirSync(join4(settingsPath, ".."), { recursive: true });
2421
2710
  const config = { permissions: { allow: allowList } };
2422
2711
  writeFileSync(settingsPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2423
2712
  return "created";
@@ -2434,21 +2723,21 @@ function ensureClaudeSettings(settingsPath) {
2434
2723
  }
2435
2724
  function resolveAssetDirWithFallback(baseDir, buildName, sourceName) {
2436
2725
  const resolved = resolveAssetDir(baseDir, buildName);
2437
- if (existsSync(resolved)) return resolved;
2726
+ if (existsSync2(resolved)) return resolved;
2438
2727
  return resolveAssetDir(baseDir, sourceName);
2439
2728
  }
2440
2729
 
2441
2730
  // apps/reaper-mcp-server/src/init.ts
2442
2731
  import { checkbox, select } from "@inquirer/prompts";
2443
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
2444
- import { join as join4 } from "node:path";
2445
- import { homedir as homedir2 } from "node:os";
2732
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "node:fs";
2733
+ import { join as join5 } from "node:path";
2734
+ import { homedir as homedir3 } from "node:os";
2446
2735
  async function runInit(opts, dirResolver) {
2447
2736
  const isTTY = Boolean(process.stdin.isTTY);
2448
2737
  const headless = opts.yes || !isTTY;
2449
2738
  console.log("REAPER MCP \u2014 Interactive Setup\n");
2450
2739
  const bridgeDir = await ensureBridgeDir();
2451
- console.log(`REAPER resource path: ${join4(bridgeDir, "..", "..")}
2740
+ console.log(`REAPER resource path: ${join5(bridgeDir, "..", "..")}
2452
2741
  `);
2453
2742
  let selections;
2454
2743
  if (headless) {
@@ -2493,27 +2782,27 @@ async function runInit(opts, dirResolver) {
2493
2782
  skillsScope
2494
2783
  };
2495
2784
  }
2496
- const __dirname2 = dirResolver();
2785
+ const __dirname3 = dirResolver();
2497
2786
  if (selections.bridge) {
2498
2787
  console.log("Installing REAPER Bridge...");
2499
2788
  const scriptsDir = getReaperScriptsPath();
2500
2789
  mkdirSync2(scriptsDir, { recursive: true });
2501
- const reaperDir = resolveAssetDir(__dirname2, "reaper");
2790
+ const reaperDir = resolveAssetDir(__dirname3, "reaper");
2502
2791
  for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
2503
- const src = join4(reaperDir, luaFile);
2504
- const dest = join4(scriptsDir, luaFile);
2792
+ const src = join5(reaperDir, luaFile);
2793
+ const dest = join5(scriptsDir, luaFile);
2505
2794
  if (installFile(src, dest)) {
2506
2795
  console.log(` Installed: ${luaFile}`);
2507
2796
  } else {
2508
2797
  console.log(` Not found: ${src}`);
2509
2798
  }
2510
2799
  }
2511
- const effectsDir = join4(getReaperEffectsPath(), "reaper-mcp");
2800
+ const effectsDir = join5(getReaperEffectsPath(), "reaper-mcp");
2512
2801
  mkdirSync2(effectsDir, { recursive: true });
2513
2802
  for (const asset of REAPER_ASSETS) {
2514
2803
  if (asset.endsWith(".lua")) continue;
2515
- const src = join4(reaperDir, asset);
2516
- const dest = join4(effectsDir, asset);
2804
+ const src = join5(reaperDir, asset);
2805
+ const dest = join5(effectsDir, asset);
2517
2806
  if (installFile(src, dest)) {
2518
2807
  console.log(` Installed: reaper-mcp/${asset}`);
2519
2808
  } else {
@@ -2524,32 +2813,32 @@ async function runInit(opts, dirResolver) {
2524
2813
  }
2525
2814
  if (selections.skills) {
2526
2815
  const isGlobal = selections.skillsScope === "global";
2527
- const baseDir = isGlobal ? join4(homedir2(), ".claude") : process.cwd();
2528
- const claudeDir = isGlobal ? baseDir : join4(baseDir, ".claude");
2816
+ const baseDir = isGlobal ? join5(homedir3(), ".claude") : process.cwd();
2817
+ const claudeDir = isGlobal ? baseDir : join5(baseDir, ".claude");
2529
2818
  console.log(`Installing AI Skills & Agents (${selections.skillsScope})...`);
2530
- const knowledgeSrc = resolveAssetDir(__dirname2, "knowledge");
2531
- if (existsSync2(knowledgeSrc)) {
2532
- const dest = join4(isGlobal ? join4(homedir2(), ".claude") : process.cwd(), "knowledge");
2819
+ const knowledgeSrc = resolveAssetDir(__dirname3, "knowledge");
2820
+ if (existsSync3(knowledgeSrc)) {
2821
+ const dest = join5(isGlobal ? join5(homedir3(), ".claude") : process.cwd(), "knowledge");
2533
2822
  const count = copyDirSync(knowledgeSrc, dest);
2534
2823
  console.log(` Installed knowledge base: ${count} files`);
2535
2824
  } else {
2536
2825
  console.log(" Knowledge base not found in package. Skipping.");
2537
2826
  }
2538
- const rulesSrc = resolveAssetDirWithFallback(__dirname2, "claude-rules", join4(".claude", "rules"));
2539
- if (existsSync2(rulesSrc)) {
2540
- const dest = join4(claudeDir, "rules");
2827
+ const rulesSrc = resolveAssetDirWithFallback(__dirname3, "claude-rules", join5(".claude", "rules"));
2828
+ if (existsSync3(rulesSrc)) {
2829
+ const dest = join5(claudeDir, "rules");
2541
2830
  const count = copyDirSync(rulesSrc, dest);
2542
2831
  console.log(` Installed Claude rules: ${count} files`);
2543
2832
  }
2544
- const skillsSrc = resolveAssetDirWithFallback(__dirname2, "claude-skills", join4(".claude", "skills"));
2545
- if (existsSync2(skillsSrc)) {
2546
- const dest = join4(claudeDir, "skills");
2833
+ const skillsSrc = resolveAssetDirWithFallback(__dirname3, "claude-skills", join5(".claude", "skills"));
2834
+ if (existsSync3(skillsSrc)) {
2835
+ const dest = join5(claudeDir, "skills");
2547
2836
  const count = copyDirSync(skillsSrc, dest);
2548
2837
  console.log(` Installed Claude skills: ${count} files`);
2549
2838
  }
2550
- const agentsSrc = resolveAssetDirWithFallback(__dirname2, "claude-agents", join4(".claude", "agents"));
2551
- if (existsSync2(agentsSrc)) {
2552
- const dest = join4(claudeDir, "agents");
2839
+ const agentsSrc = resolveAssetDirWithFallback(__dirname3, "claude-agents", join5(".claude", "agents"));
2840
+ if (existsSync3(agentsSrc)) {
2841
+ const dest = join5(claudeDir, "agents");
2553
2842
  const count = copyDirSync(agentsSrc, dest);
2554
2843
  console.log(` Installed Claude agents: ${count} files`);
2555
2844
  }
@@ -2557,8 +2846,8 @@ async function runInit(opts, dirResolver) {
2557
2846
  }
2558
2847
  if (selections.settings) {
2559
2848
  console.log("Configuring Claude Code Settings...");
2560
- const settingsDir = join4(homedir2(), ".claude");
2561
- const settingsPath = join4(settingsDir, "settings.json");
2849
+ const settingsDir = join5(homedir3(), ".claude");
2850
+ const settingsPath = join5(settingsDir, "settings.json");
2562
2851
  const result = ensureClaudeSettings(settingsPath);
2563
2852
  if (result === "created") {
2564
2853
  console.log(` Created: ${settingsPath}`);
@@ -2571,7 +2860,7 @@ async function runInit(opts, dirResolver) {
2571
2860
  }
2572
2861
  if (selections.projectConfig) {
2573
2862
  console.log("Creating Project Config...");
2574
- const mcpJsonPath = join4(process.cwd(), ".mcp.json");
2863
+ const mcpJsonPath = join5(process.cwd(), ".mcp.json");
2575
2864
  if (createMcpJson(mcpJsonPath)) {
2576
2865
  console.log(` Created: ${mcpJsonPath}`);
2577
2866
  } else {
@@ -2582,18 +2871,18 @@ async function runInit(opts, dirResolver) {
2582
2871
  console.log("Running system check...");
2583
2872
  const bridgeRunning = await isBridgeRunning();
2584
2873
  console.log(` Lua bridge: ${bridgeRunning ? "Connected" : "Not detected (start after REAPER is open)"}`);
2585
- const globalClaudeDir = join4(homedir2(), ".claude");
2586
- const localAgents = existsSync2(join4(process.cwd(), ".claude", "agents"));
2587
- const globalAgents = existsSync2(join4(globalClaudeDir, "agents"));
2874
+ const globalClaudeDir = join5(homedir3(), ".claude");
2875
+ const localAgents = existsSync3(join5(process.cwd(), ".claude", "agents"));
2876
+ const globalAgents = existsSync3(join5(globalClaudeDir, "agents"));
2588
2877
  const agentsExist = localAgents || globalAgents;
2589
2878
  console.log(` Mix agents: ${agentsExist ? "Installed" : "Not installed"}`);
2590
- const mcpJsonExists = existsSync2(join4(process.cwd(), ".mcp.json"));
2879
+ const mcpJsonExists = existsSync3(join5(process.cwd(), ".mcp.json"));
2591
2880
  console.log(` MCP config: ${mcpJsonExists ? ".mcp.json found" : ".mcp.json not present"}`);
2592
2881
  console.log("");
2593
2882
  console.log("Setup complete! Next steps:");
2594
2883
  if (selections.bridge) {
2595
2884
  const scriptsDir = getReaperScriptsPath();
2596
- const luaDest = join4(scriptsDir, "mcp_bridge.lua");
2885
+ const luaDest = join5(scriptsDir, "mcp_bridge.lua");
2597
2886
  console.log(" 1. Open REAPER");
2598
2887
  console.log(" 2. Actions > Show action list > Load ReaScript");
2599
2888
  console.log(` 3. Select: ${luaDest}`);
@@ -2608,33 +2897,206 @@ async function runInit(opts, dirResolver) {
2608
2897
  }
2609
2898
  }
2610
2899
 
2611
- // apps/reaper-mcp-server/src/main.ts
2900
+ // apps/reaper-mcp-server/src/setup-sidecar.ts
2901
+ import { exec as execImpl } from "node:child_process";
2902
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, copyFileSync as copyFileSync2, rmSync } from "node:fs";
2903
+ import { join as join6, dirname as dirname2 } from "node:path";
2904
+ import { homedir as homedir4, platform as platform3 } from "node:os";
2905
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2906
+ var VENV_BIN2 = platform3() === "win32" ? "Scripts" : "bin";
2907
+ var VENV_PYTHON_NAME2 = platform3() === "win32" ? "python.exe" : "python";
2908
+ var VENV_PIP_NAME = platform3() === "win32" ? "pip.exe" : "pip";
2909
+ function exec(cmd) {
2910
+ return new Promise((resolve, reject) => {
2911
+ execImpl(cmd, (err, stdout, stderr) => {
2912
+ if (err) reject(err);
2913
+ else resolve({ stdout, stderr });
2914
+ });
2915
+ });
2916
+ }
2612
2917
  var __dirname = dirname2(fileURLToPath2(import.meta.url));
2918
+ var SIDECAR_VENV_PATH2 = join6(homedir4(), ".reaper-mcp", "sidecar-venv");
2919
+ var SIDECAR_DIR = join6(homedir4(), ".reaper-mcp", "sidecar");
2920
+ var SIDECAR_SCRIPT_DEST = join6(SIDECAR_DIR, "server.py");
2921
+ var MODEL_ID = "facebook/audiobox-aesthetics";
2922
+ var MIN_PYTHON_MAJOR = 3;
2923
+ var MIN_PYTHON_MINOR = 10;
2924
+ function getPythonBin() {
2925
+ return process.env["PYTHON_BIN"] ?? "python3";
2926
+ }
2927
+ function parsePythonVersion(output) {
2928
+ const match = output.match(/Python\s+(\d+)\.(\d+)\.(\d+)/i);
2929
+ if (!match) return null;
2930
+ return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
2931
+ }
2932
+ async function checkPython(pythonBin) {
2933
+ let stdout;
2934
+ try {
2935
+ const result = await exec(`"${pythonBin}" --version`);
2936
+ stdout = (result.stdout + result.stderr).trim();
2937
+ } catch {
2938
+ const notFound = `Python interpreter not found: ${pythonBin}
2939
+ Install Python 3.10+ from https://python.org or set the PYTHON_BIN environment variable.
2940
+ Example: PYTHON_BIN=/usr/local/bin/python3.11 node dist/apps/reaper-mcp-server/main.js setup-sidecar`;
2941
+ throw new Error(notFound);
2942
+ }
2943
+ const version = parsePythonVersion(stdout);
2944
+ if (!version) {
2945
+ throw new Error(`Could not parse Python version from: "${stdout}". Is PYTHON_BIN set correctly?`);
2946
+ }
2947
+ const [major, minor, patch] = version;
2948
+ if (major < MIN_PYTHON_MAJOR || major === MIN_PYTHON_MAJOR && minor < MIN_PYTHON_MINOR) {
2949
+ throw new Error(
2950
+ `Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ required. Found: ${major}.${minor}.${patch} at ${pythonBin}.
2951
+ Install Python 3.10+ from https://python.org or set PYTHON_BIN to a newer interpreter.`
2952
+ );
2953
+ }
2954
+ return `${major}.${minor}.${patch}`;
2955
+ }
2956
+ async function createVenv(pythonBin) {
2957
+ mkdirSync3(join6(homedir4(), ".reaper-mcp"), { recursive: true });
2958
+ await exec(`"${pythonBin}" -m venv --clear "${SIDECAR_VENV_PATH2}"`);
2959
+ }
2960
+ function cleanupBrokenVenv() {
2961
+ try {
2962
+ if (existsSync4(SIDECAR_VENV_PATH2)) {
2963
+ rmSync(SIDECAR_VENV_PATH2, { recursive: true, force: true });
2964
+ }
2965
+ } catch {
2966
+ }
2967
+ }
2968
+ function resolveRequirementsTxt(baseDir) {
2969
+ const sidecarDir = resolveAssetDir(baseDir, "sidecar");
2970
+ return join6(sidecarDir, "requirements.txt");
2971
+ }
2972
+ async function installDeps(requirementsPath) {
2973
+ const pip = join6(SIDECAR_VENV_PATH2, VENV_BIN2, VENV_PIP_NAME);
2974
+ await exec(`"${pip}" install --upgrade pip`);
2975
+ const { stdout, stderr } = await exec(`"${pip}" install -r "${requirementsPath}"`);
2976
+ if (stdout) process.stdout.write(stdout);
2977
+ if (stderr) process.stderr.write(stderr);
2978
+ }
2979
+ async function downloadModelWeights() {
2980
+ const python = join6(SIDECAR_VENV_PATH2, VENV_BIN2, VENV_PYTHON_NAME2);
2981
+ const script = [
2982
+ "from huggingface_hub import snapshot_download",
2983
+ `print("Downloading ${MODEL_ID} weights to ~/.cache/huggingface/hub/ ...")`,
2984
+ `snapshot_download("${MODEL_ID}")`,
2985
+ `print("Download complete.")`
2986
+ ].join("; ");
2987
+ const { stdout, stderr } = await exec(`"${python}" -c "${script}"`);
2988
+ if (stdout) process.stdout.write(stdout);
2989
+ if (stderr) process.stderr.write(stderr);
2990
+ }
2991
+ function copySidecarScript(baseDir) {
2992
+ const sidecarDir = resolveAssetDir(baseDir, "sidecar");
2993
+ const src = join6(sidecarDir, "server.py");
2994
+ if (!existsSync4(src)) {
2995
+ throw new Error(
2996
+ `sidecar/server.py not found at: ${src}
2997
+ Run \`pnpm nx build reaper-mcp-server\` first.`
2998
+ );
2999
+ }
3000
+ mkdirSync3(SIDECAR_DIR, { recursive: true });
3001
+ copyFileSync2(src, SIDECAR_SCRIPT_DEST);
3002
+ }
3003
+ async function setupSidecar() {
3004
+ console.log("REAPER MCP \u2014 Setup Python Sidecar\n");
3005
+ console.log("This installs the opt-in audio AI subsystem for perceptual analysis.");
3006
+ console.log("Requires Python 3.10+ and an internet connection (~831 MB download).\n");
3007
+ const pythonBin = getPythonBin();
3008
+ console.log(`Checking Python interpreter: ${pythonBin}`);
3009
+ let version;
3010
+ try {
3011
+ version = await checkPython(pythonBin);
3012
+ } catch (err) {
3013
+ console.error(`
3014
+ Error: ${err instanceof Error ? err.message : String(err)}`);
3015
+ process.exit(1);
3016
+ }
3017
+ console.log(` Using Python interpreter: ${pythonBin} (${version})`);
3018
+ console.log(`
3019
+ Creating virtual environment at: ${SIDECAR_VENV_PATH2}`);
3020
+ try {
3021
+ await createVenv(pythonBin);
3022
+ console.log(" Virtual environment created.");
3023
+ } catch (err) {
3024
+ console.error(`
3025
+ Failed to create venv: ${err instanceof Error ? err.message : String(err)}`);
3026
+ process.exit(1);
3027
+ }
3028
+ const requirementsPath = resolveRequirementsTxt(__dirname);
3029
+ console.log(`
3030
+ Installing Python dependencies from: ${requirementsPath}`);
3031
+ if (!existsSync4(requirementsPath)) {
3032
+ console.error(`
3033
+ Error: requirements.txt not found at: ${requirementsPath}`);
3034
+ console.error("Run `pnpm nx build reaper-mcp-server` first.");
3035
+ process.exit(1);
3036
+ }
3037
+ try {
3038
+ await installDeps(requirementsPath);
3039
+ console.log(" Dependencies installed.");
3040
+ } catch (err) {
3041
+ console.error(`
3042
+ Failed to install dependencies: ${err instanceof Error ? err.message : String(err)}`);
3043
+ cleanupBrokenVenv();
3044
+ console.error("Removed partial venv so the next setup-sidecar run starts clean.");
3045
+ process.exit(1);
3046
+ }
3047
+ console.log("\nPre-downloading Audiobox Aesthetics model weights (~831 MB)...");
3048
+ console.log("This may take several minutes depending on your internet connection.");
3049
+ try {
3050
+ await downloadModelWeights();
3051
+ } catch (err) {
3052
+ console.error(`
3053
+ Model download failed: ${err instanceof Error ? err.message : String(err)}`);
3054
+ console.error("Check your internet connection and run setup-sidecar again.");
3055
+ process.exit(1);
3056
+ }
3057
+ console.log(`
3058
+ Installing sidecar script to: ${SIDECAR_SCRIPT_DEST}`);
3059
+ try {
3060
+ copySidecarScript(__dirname);
3061
+ console.log(" Sidecar script installed.");
3062
+ } catch (err) {
3063
+ console.error(`
3064
+ Failed to install sidecar script: ${err instanceof Error ? err.message : String(err)}`);
3065
+ process.exit(1);
3066
+ }
3067
+ console.log("\nSidecar setup complete!\n");
3068
+ console.log("The analyze_track_aesthetics tool is now available.");
3069
+ console.log("Run `node dist/apps/reaper-mcp-server/main.js doctor` to verify.\n");
3070
+ }
3071
+
3072
+ // apps/reaper-mcp-server/src/main.ts
3073
+ var execAsync = promisifyUtil(execCb);
3074
+ var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
2613
3075
  async function setup() {
2614
3076
  console.log("REAPER MCP Server \u2014 Setup\n");
2615
3077
  const bridgeDir = await ensureBridgeDir();
2616
3078
  console.log(`Bridge directory: ${bridgeDir}
2617
3079
  `);
2618
3080
  const scriptsDir = getReaperScriptsPath();
2619
- mkdirSync3(scriptsDir, { recursive: true });
2620
- const reaperDir = resolveAssetDir(__dirname, "reaper");
3081
+ mkdirSync4(scriptsDir, { recursive: true });
3082
+ const reaperDir = resolveAssetDir(__dirname2, "reaper");
2621
3083
  console.log("Installing Lua scripts...");
2622
3084
  for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
2623
- const src = join5(reaperDir, luaFile);
2624
- const dest = join5(scriptsDir, luaFile);
3085
+ const src = join7(reaperDir, luaFile);
3086
+ const dest = join7(scriptsDir, luaFile);
2625
3087
  if (installFile(src, dest)) {
2626
3088
  console.log(` Installed: ${luaFile}`);
2627
3089
  } else {
2628
3090
  console.log(` Not found: ${src}`);
2629
3091
  }
2630
3092
  }
2631
- const effectsDir = join5(getReaperEffectsPath(), "reaper-mcp");
2632
- mkdirSync3(effectsDir, { recursive: true });
3093
+ const effectsDir = join7(getReaperEffectsPath(), "reaper-mcp");
3094
+ mkdirSync4(effectsDir, { recursive: true });
2633
3095
  console.log("\nInstalling JSFX analyzers...");
2634
3096
  for (const asset of REAPER_ASSETS) {
2635
3097
  if (asset.endsWith(".lua")) continue;
2636
- const src = join5(reaperDir, asset);
2637
- const dest = join5(effectsDir, asset);
3098
+ const src = join7(reaperDir, asset);
3099
+ const dest = join7(effectsDir, asset);
2638
3100
  if (installFile(src, dest)) {
2639
3101
  console.log(` Installed: reaper-mcp/${asset}`);
2640
3102
  } else {
@@ -2645,7 +3107,7 @@ async function setup() {
2645
3107
  console.log("Next steps:");
2646
3108
  console.log(" 1. Open REAPER");
2647
3109
  console.log(" 2. Actions > Show action list > Load ReaScript");
2648
- console.log(` 3. Select: ${join5(scriptsDir, "mcp_bridge.lua")}`);
3110
+ console.log(` 3. Select: ${join7(scriptsDir, "mcp_bridge.lua")}`);
2649
3111
  console.log(" 4. Run the script (it will keep running in background via defer loop)");
2650
3112
  console.log(" 5. Add reaper-mcp to your Claude Code config (see: npx @mthines/reaper-mcp doctor)");
2651
3113
  }
@@ -2657,41 +3119,41 @@ async function installSkills(scope) {
2657
3119
  console.log(`REAPER MCP \u2014 Install AI Mix Engineer Skills (scope: ${scope})
2658
3120
  `);
2659
3121
  const isGlobal = scope === "global";
2660
- const baseDir = isGlobal ? join5(homedir3(), ".claude") : process.cwd();
2661
- const claudeDir = isGlobal ? baseDir : join5(baseDir, ".claude");
2662
- const knowledgeSrc = resolveAssetDir(__dirname, "knowledge");
2663
- if (existsSync3(knowledgeSrc)) {
2664
- const dest = join5(baseDir, "knowledge");
3122
+ const baseDir = isGlobal ? join7(homedir5(), ".claude") : process.cwd();
3123
+ const claudeDir = isGlobal ? baseDir : join7(baseDir, ".claude");
3124
+ const knowledgeSrc = resolveAssetDir(__dirname2, "knowledge");
3125
+ if (existsSync5(knowledgeSrc)) {
3126
+ const dest = join7(baseDir, "knowledge");
2665
3127
  const count = copyDirSync(knowledgeSrc, dest);
2666
3128
  console.log(`Installed knowledge base: ${count} files \u2192 ${dest}`);
2667
3129
  } else {
2668
3130
  console.log("Knowledge base not found in package. Skipping.");
2669
3131
  }
2670
- const rulesSrc = resolveAssetDirWithFallback(__dirname, "claude-rules", join5(".claude", "rules"));
2671
- if (existsSync3(rulesSrc)) {
2672
- const dest = join5(claudeDir, "rules");
3132
+ const rulesSrc = resolveAssetDirWithFallback(__dirname2, "claude-rules", join7(".claude", "rules"));
3133
+ if (existsSync5(rulesSrc)) {
3134
+ const dest = join7(claudeDir, "rules");
2673
3135
  const count = copyDirSync(rulesSrc, dest);
2674
3136
  console.log(`Installed Claude rules: ${count} files \u2192 ${dest}`);
2675
3137
  } else {
2676
3138
  console.log("Claude rules not found in package. Skipping.");
2677
3139
  }
2678
- const skillsSrc = resolveAssetDirWithFallback(__dirname, "claude-skills", join5(".claude", "skills"));
2679
- if (existsSync3(skillsSrc)) {
2680
- const dest = join5(claudeDir, "skills");
3140
+ const skillsSrc = resolveAssetDirWithFallback(__dirname2, "claude-skills", join7(".claude", "skills"));
3141
+ if (existsSync5(skillsSrc)) {
3142
+ const dest = join7(claudeDir, "skills");
2681
3143
  const count = copyDirSync(skillsSrc, dest);
2682
3144
  console.log(`Installed Claude skills: ${count} files \u2192 ${dest}`);
2683
3145
  } else {
2684
3146
  console.log("Claude skills not found in package. Skipping.");
2685
3147
  }
2686
- const agentsSrc = resolveAssetDirWithFallback(__dirname, "claude-agents", join5(".claude", "agents"));
2687
- if (existsSync3(agentsSrc)) {
2688
- const dest = join5(claudeDir, "agents");
3148
+ const agentsSrc = resolveAssetDirWithFallback(__dirname2, "claude-agents", join7(".claude", "agents"));
3149
+ if (existsSync5(agentsSrc)) {
3150
+ const dest = join7(claudeDir, "agents");
2689
3151
  const count = copyDirSync(agentsSrc, dest);
2690
3152
  console.log(`Installed Claude agents: ${count} files \u2192 ${dest}`);
2691
3153
  } else {
2692
3154
  console.log("Claude agents not found in package. Skipping.");
2693
3155
  }
2694
- const settingsPath = join5(claudeDir, "settings.json");
3156
+ const settingsPath = join7(claudeDir, "settings.json");
2695
3157
  const result = ensureClaudeSettings(settingsPath);
2696
3158
  if (result === "created") {
2697
3159
  console.log(`Created Claude settings: ${settingsPath}`);
@@ -2701,7 +3163,7 @@ async function installSkills(scope) {
2701
3163
  console.log(`Claude settings already has all REAPER tools: ${settingsPath}`);
2702
3164
  }
2703
3165
  if (!isGlobal) {
2704
- const mcpJsonPath = join5(baseDir, ".mcp.json");
3166
+ const mcpJsonPath = join7(baseDir, ".mcp.json");
2705
3167
  if (createMcpJson(mcpJsonPath)) {
2706
3168
  console.log(`
2707
3169
  Created: ${mcpJsonPath}`);
@@ -2722,31 +3184,92 @@ async function doctor() {
2722
3184
  if (!bridgeRunning) {
2723
3185
  console.log(' \u2192 Run "npx @mthines/reaper-mcp setup" then load mcp_bridge.lua in REAPER');
2724
3186
  }
2725
- const globalClaudeDir = join5(homedir3(), ".claude");
2726
- const localAgents = existsSync3(join5(process.cwd(), ".claude", "agents"));
2727
- const globalAgents = existsSync3(join5(globalClaudeDir, "agents"));
3187
+ const globalClaudeDir = join7(homedir5(), ".claude");
3188
+ const localAgents = existsSync5(join7(process.cwd(), ".claude", "agents"));
3189
+ const globalAgents = existsSync5(join7(globalClaudeDir, "agents"));
2728
3190
  const agentsExist = localAgents || globalAgents;
2729
3191
  const agentsLocation = localAgents ? ".claude/agents/" : globalAgents ? "~/.claude/agents/" : "";
2730
3192
  console.log(`Mix agents: ${agentsExist ? `\u2713 Found (${agentsLocation})` : "\u2717 Not installed"}`);
2731
3193
  if (!agentsExist) {
2732
3194
  console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills"');
2733
3195
  }
2734
- const localKnowledge = existsSync3(join5(process.cwd(), "knowledge"));
2735
- const globalKnowledge = existsSync3(join5(globalClaudeDir, "knowledge"));
3196
+ const localKnowledge = existsSync5(join7(process.cwd(), "knowledge"));
3197
+ const globalKnowledge = existsSync5(join7(globalClaudeDir, "knowledge"));
2736
3198
  const knowledgeExists = localKnowledge || globalKnowledge;
2737
3199
  const knowledgeLocation = localKnowledge ? "project" : globalKnowledge ? "~/.claude/" : "";
2738
3200
  console.log(`Knowledge base: ${knowledgeExists ? `\u2713 Found (${knowledgeLocation})` : "\u2717 Not installed"}`);
2739
3201
  if (!knowledgeExists) {
2740
3202
  console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills"');
2741
3203
  }
2742
- const mcpJsonExists = existsSync3(join5(process.cwd(), ".mcp.json"));
3204
+ const mcpJsonExists = existsSync5(join7(process.cwd(), ".mcp.json"));
2743
3205
  console.log(`MCP config: ${mcpJsonExists ? "\u2713 .mcp.json found" : "\u2717 .mcp.json missing"}`);
2744
3206
  if (!mcpJsonExists) {
2745
3207
  console.log(' \u2192 Run "npx @mthines/reaper-mcp install-skills --project" to create .mcp.json');
2746
3208
  }
2747
3209
  console.log('\nTo check SWS Extensions, start REAPER and use the "list_available_fx" tool.');
2748
3210
  console.log("SWS provides enhanced plugin discovery and snapshot support.\n");
2749
- process.exit(bridgeRunning && knowledgeExists && mcpJsonExists ? 0 : 1);
3211
+ console.log("Python Sidecar (opt-in, for analyze_track_aesthetics):");
3212
+ const sidecarVenvPath = join7(homedir5(), ".reaper-mcp", "sidecar-venv");
3213
+ const venvBin = platform4() === "win32" ? "Scripts" : "bin";
3214
+ const venvPythonName = platform4() === "win32" ? "python.exe" : "python";
3215
+ const venvPython = join7(sidecarVenvPath, venvBin, venvPythonName);
3216
+ const hfCachePath = join7(homedir5(), ".cache", "huggingface", "hub");
3217
+ let pythonOk = false;
3218
+ let pythonDetail = "not found";
3219
+ try {
3220
+ const pythonBin = process.env["PYTHON_BIN"] ?? "python3";
3221
+ const { stdout, stderr } = await execAsync(`"${pythonBin}" --version`);
3222
+ const versionStr = (stdout + stderr).trim();
3223
+ const match = versionStr.match(/Python\s+(\d+)\.(\d+)/i);
3224
+ if (match) {
3225
+ const major = parseInt(match[1], 10);
3226
+ const minor = parseInt(match[2], 10);
3227
+ pythonOk = major > 3 || major === 3 && minor >= 10;
3228
+ pythonDetail = versionStr;
3229
+ }
3230
+ } catch {
3231
+ pythonDetail = "not found";
3232
+ }
3233
+ console.log(` Python \u2265 3.10: ${pythonOk ? `\u2713 ${pythonDetail}` : `\u2717 ${pythonDetail}`}`);
3234
+ if (!pythonOk) {
3235
+ console.log(" \u2192 Install Python 3.10+ from https://python.org or set PYTHON_BIN");
3236
+ }
3237
+ const venvExists = existsSync5(sidecarVenvPath);
3238
+ console.log(` Venv: ${venvExists ? `\u2713 ${sidecarVenvPath}` : "\u2717 Not installed"}`);
3239
+ if (!venvExists) {
3240
+ console.log(" \u2192 Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar");
3241
+ }
3242
+ let depsOk = false;
3243
+ if (venvExists && existsSync5(venvPython)) {
3244
+ try {
3245
+ await execAsync(`"${venvPython}" -c "import audiobox_aesthetics"`);
3246
+ depsOk = true;
3247
+ } catch {
3248
+ depsOk = false;
3249
+ }
3250
+ }
3251
+ console.log(` Dependencies: ${depsOk ? "\u2713 audiobox-aesthetics importable" : "\u2717 Not installed"}`);
3252
+ if (!depsOk && venvExists) {
3253
+ console.log(" \u2192 Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar");
3254
+ }
3255
+ const weightsPath = join7(hfCachePath, "models--facebook--audiobox-aesthetics");
3256
+ const weightsExist = existsSync5(weightsPath);
3257
+ console.log(` Model weights: ${weightsExist ? `\u2713 ${weightsPath}` : "\u2717 Not downloaded"}`);
3258
+ if (!weightsExist) {
3259
+ console.log(" \u2192 Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar");
3260
+ }
3261
+ const sidecarReady = venvExists && depsOk && weightsExist;
3262
+ const sidecarOptedIn = venvExists || weightsExist;
3263
+ const sidecarBroken = sidecarOptedIn && !sidecarReady;
3264
+ if (!sidecarOptedIn) {
3265
+ console.log("\n Sidecar: not installed (opt-in). Run setup-sidecar to enable audio AI tools.");
3266
+ } else if (sidecarBroken) {
3267
+ console.log("\n Sidecar: PARTIALLY INSTALLED \u2014 run setup-sidecar to repair.");
3268
+ } else {
3269
+ console.log("\n Sidecar: fully installed and ready.");
3270
+ }
3271
+ console.log("");
3272
+ process.exit(bridgeRunning && knowledgeExists && mcpJsonExists && !sidecarBroken ? 0 : 1);
2750
3273
  }
2751
3274
  async function serve() {
2752
3275
  const log = (...args) => console.error("[reaper-mcp]", ...args);
@@ -2755,7 +3278,7 @@ async function serve() {
2755
3278
  startDiagnosticsPoller();
2756
3279
  startEventsPoller();
2757
3280
  log("Starting REAPER MCP Server...");
2758
- log(`Entry: ${fileURLToPath2(import.meta.url)}`);
3281
+ log(`Entry: ${fileURLToPath3(import.meta.url)}`);
2759
3282
  const tracer = getTracer();
2760
3283
  await tracer.startActiveSpan("mcp.server.startup", { kind: SpanKind3.INTERNAL }, async (startupSpan) => {
2761
3284
  try {
@@ -2809,7 +3332,7 @@ switch (command) {
2809
3332
  case "init":
2810
3333
  runInit(
2811
3334
  { yes: hasYesFlag, project: hasProjectFlag },
2812
- () => __dirname
3335
+ () => __dirname2
2813
3336
  ).catch((err) => {
2814
3337
  console.error("Init failed:", err);
2815
3338
  process.exit(1);
@@ -2833,6 +3356,12 @@ switch (command) {
2833
3356
  process.exit(1);
2834
3357
  });
2835
3358
  break;
3359
+ case "setup-sidecar":
3360
+ setupSidecar().catch((err) => {
3361
+ console.error("Sidecar setup failed:", err);
3362
+ process.exit(1);
3363
+ });
3364
+ break;
2836
3365
  case "status": {
2837
3366
  (async () => {
2838
3367
  const running = await isBridgeRunning();
@@ -2858,6 +3387,7 @@ Usage:
2858
3387
  npx @mthines/reaper-mcp init --yes Non-interactive setup (install everything with defaults)
2859
3388
  npx @mthines/reaper-mcp init --project Include .mcp.json in current directory
2860
3389
  npx @mthines/reaper-mcp setup Install Lua bridge + JSFX analyzers into REAPER
3390
+ npx @mthines/reaper-mcp setup-sidecar Install Python sidecar for perceptual audio analysis (opt-in, ~831 MB)
2861
3391
  npx @mthines/reaper-mcp install-skills Install AI knowledge + agents globally (default)
2862
3392
  npx @mthines/reaper-mcp install-skills --project Install into current project directory
2863
3393
  npx @mthines/reaper-mcp install-skills --global Install into ~/.claude/ (default)
@@ -2879,24 +3409,31 @@ Tip: install globally for shorter commands:
2879
3409
  `);
2880
3410
  break;
2881
3411
  }
3412
+ function shutdownAll() {
3413
+ stopPollers();
3414
+ try {
3415
+ getSidecarClient().shutdown();
3416
+ } catch {
3417
+ }
3418
+ }
2882
3419
  process.on("SIGINT", () => {
2883
3420
  console.error("[reaper-mcp] Interrupted");
2884
- stopPollers();
3421
+ shutdownAll();
2885
3422
  shutdownTelemetry().finally(() => process.exit(0));
2886
3423
  });
2887
3424
  process.on("SIGTERM", () => {
2888
3425
  console.error("[reaper-mcp] Terminated");
2889
- stopPollers();
3426
+ shutdownAll();
2890
3427
  shutdownTelemetry().finally(() => process.exit(0));
2891
3428
  });
2892
3429
  process.on("uncaughtException", (err) => {
2893
3430
  console.error("[reaper-mcp] Uncaught exception:", err);
2894
- stopPollers();
3431
+ shutdownAll();
2895
3432
  shutdownTelemetry().finally(() => process.exit(1));
2896
3433
  });
2897
3434
  process.on("unhandledRejection", (reason) => {
2898
3435
  console.error("[reaper-mcp] Unhandled rejection:", reason);
2899
- stopPollers();
3436
+ shutdownAll();
2900
3437
  shutdownTelemetry().finally(() => process.exit(1));
2901
3438
  });
2902
3439
  //# sourceMappingURL=main.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.0-beta.19.2",
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",
@@ -3499,6 +3499,187 @@ function handlers.insert_envelope_points(params)
3499
3499
  }
3500
3500
  end
3501
3501
 
3502
+ -- =============================================================================
3503
+ -- render_track_to_wav handler
3504
+ --
3505
+ -- Renders a target track's output (post-FX, post-fader) to a temp WAV file.
3506
+ -- Saves and restores all render settings and solo states around the render.
3507
+ -- Blocks the defer loop during render (~0.5-1s) — this is by design.
3508
+ --
3509
+ -- Params:
3510
+ -- trackIndex (number) — zero-based track index
3511
+ -- startTime (number) — render start, seconds from project start
3512
+ -- endTime (number) — render end, seconds from project start
3513
+ -- commandId (string) — used to generate a unique temp filename
3514
+ --
3515
+ -- Returns:
3516
+ -- { trackIndex, trackName, wavPath, durationSeconds, sampleRate, channelCount }
3517
+ -- =============================================================================
3518
+
3519
+ function handlers.render_track_to_wav(params)
3520
+ local track_index = params.trackIndex
3521
+ local start_time = params.startTime
3522
+ local end_time = params.endTime
3523
+ local command_id = params.commandId or tostring(os.time())
3524
+
3525
+ -- Validate inputs
3526
+ if track_index == nil then
3527
+ return nil, "Missing required param: trackIndex"
3528
+ end
3529
+ if start_time == nil or end_time == nil then
3530
+ return nil, "Missing required params: startTime, endTime"
3531
+ end
3532
+ if end_time <= start_time then
3533
+ return nil, "endTime must be greater than startTime"
3534
+ end
3535
+
3536
+ -- Get track
3537
+ local track = reaper.GetTrack(0, track_index)
3538
+ if not track then
3539
+ return nil, "Track " .. tostring(track_index) .. " not found"
3540
+ end
3541
+ local track_name = ({reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "", false)})[2] or ""
3542
+ if track_name == "" then track_name = "Track " .. (track_index + 1) end
3543
+
3544
+ -- Render target is always 44100 Hz (WAV for sidecar inference).
3545
+ -- The actual project sample rate is irrelevant here; Audiobox resamples internally.
3546
+ local sample_rate = 44100
3547
+
3548
+ -- Build unique temp file path
3549
+ -- Use GetTempPath() if available (REAPER 6.29+), fall back to os.tmpname pattern
3550
+ local temp_dir
3551
+ if reaper.GetTempPath then
3552
+ temp_dir = reaper.GetTempPath()
3553
+ else
3554
+ -- Fallback: derive temp dir from os.tmpname()
3555
+ local t = os.tmpname()
3556
+ temp_dir = t:match("^(.+)[/\\][^/\\]+$") or "/tmp"
3557
+ os.remove(t)
3558
+ end
3559
+
3560
+ -- Ensure temp dir ends with separator
3561
+ if temp_dir:sub(-1) ~= "/" and temp_dir:sub(-1) ~= "\\" then
3562
+ temp_dir = temp_dir .. "/"
3563
+ end
3564
+
3565
+ local wav_filename = "mcp_aes_" .. command_id:gsub("-", ""):sub(1, 16) .. ".wav"
3566
+ local wav_path = temp_dir .. wav_filename
3567
+
3568
+ -- -------------------------------------------------------------------------
3569
+ -- Save current render settings
3570
+ -- -------------------------------------------------------------------------
3571
+ local saved_file = ({reaper.GetSetProjectInfo_String(0, "RENDER_FILE", "", false)})[2] or ""
3572
+ local saved_pattern = ({reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", "", false)})[2] or ""
3573
+ local saved_format = ({reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "", false)})[2] or ""
3574
+ local saved_bounds = reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, false)
3575
+ local saved_settings = reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 0, false)
3576
+ local saved_start = reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", 0, false)
3577
+ local saved_end = reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", 0, false)
3578
+ local saved_srate = reaper.GetSetProjectInfo(0, "RENDER_SRATE", 0, false)
3579
+ local saved_channels = reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 0, false)
3580
+
3581
+ -- Save solo states for all tracks
3582
+ local track_count = reaper.CountTracks(0)
3583
+ local saved_solo_states = {}
3584
+ for i = 0, track_count - 1 do
3585
+ local t = reaper.GetTrack(0, i)
3586
+ saved_solo_states[i] = reaper.GetMediaTrackInfo_Value(t, "I_SOLO")
3587
+ end
3588
+
3589
+ -- -------------------------------------------------------------------------
3590
+ -- Apply render settings
3591
+ -- -------------------------------------------------------------------------
3592
+ local ok, restore_err = pcall(function()
3593
+ -- Set output: render to temp dir with unique filename (no extension in pattern)
3594
+ reaper.GetSetProjectInfo_String(0, "RENDER_FILE", temp_dir, true)
3595
+ reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", wav_filename:match("^(.+)%.wav$") or wav_filename, true)
3596
+
3597
+ -- Set render bounds: 0 = custom time range
3598
+ reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, true)
3599
+ reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", start_time, true)
3600
+ reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", end_time, true)
3601
+
3602
+ -- Set render mode: 2 = stems (selected tracks via master mix / solo)
3603
+ reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 2, true)
3604
+
3605
+ -- Force WAV at 44100 Hz stereo so the sidecar always gets a known format,
3606
+ -- independent of whatever the user last rendered. Restored in finally.
3607
+ -- "evaw" is the little-endian "wave" fourcc REAPER uses for WAV format.
3608
+ reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "evaw", true)
3609
+ reaper.GetSetProjectInfo(0, "RENDER_SRATE", sample_rate, true)
3610
+ reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 2, true)
3611
+
3612
+ -- Unsolo all tracks, then solo only the target track
3613
+ for i = 0, track_count - 1 do
3614
+ local t = reaper.GetTrack(0, i)
3615
+ reaper.SetMediaTrackInfo_Value(t, "I_SOLO", 0)
3616
+ end
3617
+ reaper.SetMediaTrackInfo_Value(track, "I_SOLO", 1)
3618
+
3619
+ -- Trigger render: action 42230 = "Render project, using the most recent render settings, auto-close render dialog"
3620
+ -- This call is synchronous — it blocks the defer loop until rendering completes.
3621
+ reaper.Main_OnCommand(42230, 0)
3622
+ end)
3623
+
3624
+ -- -------------------------------------------------------------------------
3625
+ -- Restore all settings and solo states (always, even on error)
3626
+ -- -------------------------------------------------------------------------
3627
+ local restore_ok, restore_err2 = pcall(function()
3628
+ reaper.GetSetProjectInfo_String(0, "RENDER_FILE", saved_file, true)
3629
+ reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", saved_pattern, true)
3630
+ reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", saved_format, true)
3631
+ reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", saved_bounds, true)
3632
+ reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", saved_settings, true)
3633
+ reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", saved_start, true)
3634
+ reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", saved_end, true)
3635
+ reaper.GetSetProjectInfo(0, "RENDER_SRATE", saved_srate, true)
3636
+ reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", saved_channels, true)
3637
+ for i = 0, track_count - 1 do
3638
+ local t = reaper.GetTrack(0, i)
3639
+ reaper.SetMediaTrackInfo_Value(t, "I_SOLO", saved_solo_states[i] or 0)
3640
+ end
3641
+ end)
3642
+
3643
+ if not restore_ok then
3644
+ -- Log but don't fail — the render may have succeeded
3645
+ reaper.ShowConsoleMsg("[reaper-mcp] Warning: failed to restore render settings: " .. tostring(restore_err2) .. "\n")
3646
+ end
3647
+
3648
+ if not ok then
3649
+ return nil, "Render failed: " .. tostring(restore_err)
3650
+ end
3651
+
3652
+ -- Verify the file was actually created.
3653
+ -- RENDER_PATTERN was set without the .wav extension so REAPER appends it;
3654
+ -- wav_path already has the full expected name including the extension.
3655
+ local f = io.open(wav_path, "rb")
3656
+ if not f then
3657
+ return nil, "Render produced no output file at: " .. wav_path
3658
+ end
3659
+ -- Verify the rendered file is actually a WAV. RENDER_FORMAT was set to "evaw"
3660
+ -- (REAPER's WAV fourcc) above; if REAPER silently ignored that value the file
3661
+ -- could be any codec the user last rendered (MP3/FLAC/etc.). Check the RIFF
3662
+ -- magic bytes so we fail loudly here rather than crashing in the Python sidecar
3663
+ -- with a confusing "Cannot read audio file" error.
3664
+ local magic = f:read(4)
3665
+ f:close()
3666
+ if magic ~= "RIFF" then
3667
+ os.remove(wav_path)
3668
+ return nil, "Render produced a non-WAV file (RENDER_FORMAT may not have been applied). " ..
3669
+ "First 4 bytes: " .. (magic and string.format("%q", magic) or "<empty>")
3670
+ end
3671
+
3672
+ local duration = end_time - start_time
3673
+ return {
3674
+ trackIndex = track_index,
3675
+ trackName = track_name,
3676
+ wavPath = wav_path,
3677
+ durationSeconds = duration,
3678
+ sampleRate = sample_rate,
3679
+ channelCount = 2,
3680
+ }
3681
+ end
3682
+
3502
3683
  -- =============================================================================
3503
3684
  -- Bridge diagnostics handler
3504
3685
  -- =============================================================================