@mthines/reaper-mcp 0.19.0 → 0.70.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.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ REAPER MCP Python Sidecar — Audiobox Aesthetics JSON-RPC Server
4
+
5
+ Long-lived process. Reads newline-delimited JSON-RPC 2.0 requests from stdin,
6
+ writes responses to stdout. Never crashes — all errors are surfaced as
7
+ JSON-RPC error responses.
8
+
9
+ Model: Meta Audiobox Aesthetics (CC-BY 4.0)
10
+ https://huggingface.co/facebook/audiobox-aesthetics
11
+
12
+ JSON-RPC request format:
13
+ {"jsonrpc":"2.0","id":1,"method":"analyze","params":{"path":"/tmp/foo.wav","startTime":0,"endTime":5}}
14
+
15
+ JSON-RPC response format (success):
16
+ {"jsonrpc":"2.0","id":1,"result":{"CE":5.15,"CU":5.78,"PC":2.15,"PQ":7.22,"durationSeconds":5.0,"modelVersion":"facebook/audiobox-aesthetics"}}
17
+
18
+ JSON-RPC error response:
19
+ {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"Audio too short: 0.3s (minimum 0.5s)"}}
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ import os
27
+ from typing import Any
28
+
29
+ import numpy as np
30
+
31
+ # analysis.py is installed next to this file
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+
34
+ # Minimum audio duration the model can process
35
+ MIN_DURATION_SECONDS = 0.5
36
+ MODEL_ID = "facebook/audiobox-aesthetics"
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Predictor singleton — loaded once at first request
40
+ # ---------------------------------------------------------------------------
41
+
42
+ _predictor = None
43
+ _predictor_error: str | None = None
44
+
45
+
46
+ def _ensure_predictor() -> None:
47
+ """Load the predictor on first use. Caches result (success or failure)."""
48
+ global _predictor, _predictor_error
49
+
50
+ if _predictor is not None or _predictor_error is not None:
51
+ return
52
+
53
+ try:
54
+ from audiobox_aesthetics.infer import initialize_predictor # type: ignore
55
+ _predictor = initialize_predictor()
56
+ except ImportError as exc:
57
+ _predictor_error = (
58
+ f"audiobox-aesthetics not installed: {exc}. "
59
+ f"Run: node dist/apps/reaper-mcp-server/main.js setup-sidecar"
60
+ )
61
+ except Exception as exc: # noqa: BLE001
62
+ _predictor_error = f"Failed to initialize predictor: {exc}"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Duration check helper
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _load_clip(path: str, start_time: float, end_time: float) -> tuple[Any, int]:
70
+ """Decode [start_time, end_time) of a file as a (channels, frames) float32 array.
71
+
72
+ Uses soundfile, not torchaudio: torchaudio 2.9+ dropped info() and moved
73
+ load() to torchcodec, which audiobox-aesthetics' own reader still calls.
74
+ """
75
+ import analysis
76
+
77
+ return analysis.load_audio(path, start_time, end_time)
78
+
79
+
80
+ def _get_audio_duration(path: str) -> float:
81
+ """Return audio duration in seconds. Raises RuntimeError if file unreadable."""
82
+ try:
83
+ import soundfile as sf # type: ignore
84
+ info = sf.info(path)
85
+ return info.duration
86
+ except Exception:
87
+ # Fallback: try torchaudio if soundfile not available
88
+ try:
89
+ import torchaudio # type: ignore
90
+ info = torchaudio.info(path)
91
+ return info.num_frames / info.sample_rate
92
+ except Exception as exc:
93
+ raise RuntimeError(f"Cannot read audio file: {path}: {exc}") from exc
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # JSON-RPC helpers
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def _json_default(value: Any) -> Any:
101
+ # numpy scalars (float32 survives arithmetic with Python floats in NumPy 2)
102
+ if isinstance(value, np.generic):
103
+ return value.item()
104
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
105
+
106
+
107
+ def _ok(request_id: Any, result: dict) -> str:
108
+ return json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}, default=_json_default)
109
+
110
+
111
+ def _err(request_id: Any, code: int, message: str) -> str:
112
+ return json.dumps({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}})
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Method dispatch
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def handle_analyze(request_id: Any, params: dict) -> str:
120
+ """
121
+ Analyze a WAV file with Audiobox Aesthetics.
122
+
123
+ params:
124
+ path — absolute path to WAV file (required)
125
+ startTime — float seconds, start of region to analyze (default 0)
126
+ endTime — float seconds, end of region (default = file duration)
127
+
128
+ Returns JSON-RPC response string.
129
+ """
130
+ path = params.get("path")
131
+ if not path:
132
+ return _err(request_id, -32602, "Missing required param: path")
133
+
134
+ if not os.path.exists(path):
135
+ return _err(request_id, -32000, f"Audio file not found: {path}")
136
+
137
+ # Check duration
138
+ try:
139
+ duration = _get_audio_duration(path)
140
+ except RuntimeError as exc:
141
+ return _err(request_id, -32000, str(exc))
142
+
143
+ start_time = float(params.get("startTime", 0))
144
+ end_time = float(params.get("endTime", duration))
145
+ normalize_lufs = params.get("normalizeLufs")
146
+ normalize_lufs = None if normalize_lufs is None else float(normalize_lufs)
147
+
148
+ clip_duration = end_time - start_time
149
+ if clip_duration < MIN_DURATION_SECONDS:
150
+ return _err(
151
+ request_id,
152
+ -32000,
153
+ f"Audio too short: {clip_duration:.2f}s (minimum {MIN_DURATION_SECONDS}s)",
154
+ )
155
+
156
+ # Ensure predictor is loaded
157
+ _ensure_predictor()
158
+ if _predictor_error:
159
+ return _err(request_id, -32000, _predictor_error)
160
+
161
+ try:
162
+ wav, sample_rate = _load_clip(path, start_time, end_time)
163
+ input_lufs = None
164
+ gain_db = 0.0
165
+ if normalize_lufs is not None:
166
+ import analysis
167
+
168
+ wav, input_lufs, gain_db = analysis.normalize_to_lufs(wav, sample_rate, normalize_lufs)
169
+ if isinstance(wav, np.ndarray):
170
+ import torch # type: ignore
171
+
172
+ wav = torch.from_numpy(wav)
173
+ # A tensor under "path" skips audiobox's own torchaudio.load
174
+ results = _predictor.forward([{"path": wav, "sample_rate": sample_rate}])
175
+ # audiobox-aesthetics returns a list of dicts with keys CE, CU, PC, PQ
176
+ scores = results[0]
177
+ return _ok(request_id, {
178
+ "CE": float(scores.get("CE", 0)),
179
+ "CU": float(scores.get("CU", 0)),
180
+ "PC": float(scores.get("PC", 0)),
181
+ "PQ": float(scores.get("PQ", 0)),
182
+ "durationSeconds": clip_duration,
183
+ "modelVersion": MODEL_ID,
184
+ "normalizeLufs": normalize_lufs,
185
+ "inputLufs": None if input_lufs is None else round(input_lufs, 2),
186
+ "gainDb": round(gain_db, 2),
187
+ })
188
+ except Exception as exc: # noqa: BLE001
189
+ return _err(request_id, -32000, f"Inference failed: {exc}")
190
+
191
+
192
+ def _check_file(request_id: Any, params: dict, key: str) -> str | None:
193
+ """Return an error response when params[key] is missing or not a file."""
194
+ path = params.get(key)
195
+ if not path:
196
+ return _err(request_id, -32602, f"Missing required param: {key}")
197
+ if not os.path.exists(path):
198
+ return _err(request_id, -32000, f"Audio file not found: {path}")
199
+ return None
200
+
201
+
202
+ def handle_analyze_audio(request_id: Any, params: dict) -> str:
203
+ """
204
+ Loudness (BS.1770 / EBU R128) and spectral analysis of a file, with an
205
+ optional reference file comparison. No model is loaded.
206
+
207
+ params: path, startTime?, endTime?, sectionSeconds?, referencePath?,
208
+ referenceStartTime?, referenceEndTime?
209
+ """
210
+ import analysis
211
+
212
+ for key in ("path", *(("referencePath",) if params.get("referencePath") else ())):
213
+ error = _check_file(request_id, params, key)
214
+ if error:
215
+ return error
216
+ try:
217
+ audio, sr = analysis.load_audio(params["path"], float(params.get("startTime", 0)),
218
+ params.get("endTime"))
219
+ if audio.shape[1] < int(0.4 * sr):
220
+ return _err(request_id, -32000, "Audio too short: need at least 0.4 s")
221
+ ref = None
222
+ if params.get("referencePath"):
223
+ ref = analysis.load_audio(params["referencePath"], float(params.get("referenceStartTime", 0)),
224
+ params.get("referenceEndTime"))
225
+ section = params.get("sectionSeconds")
226
+ result = analysis.analyze(audio, sr, float(section) if section else None, ref)
227
+ result["sampleRate"] = sr
228
+ result["channels"] = int(audio.shape[0])
229
+ return _ok(request_id, result)
230
+ except Exception as exc: # noqa: BLE001
231
+ return _err(request_id, -32000, f"Analysis failed: {exc}")
232
+
233
+
234
+ def handle_align(request_id: Any, params: dict) -> str:
235
+ """
236
+ Delay and polarity of `path` relative to `referencePath` (same length and
237
+ sample rate, e.g. two renders of the same time range).
238
+
239
+ params: referencePath, path, maxLagMs? (default 20)
240
+ """
241
+ import analysis
242
+
243
+ for key in ("referencePath", "path"):
244
+ error = _check_file(request_id, params, key)
245
+ if error:
246
+ return error
247
+ try:
248
+ ref, ref_sr = analysis.load_audio(params["referencePath"])
249
+ target, sr = analysis.load_audio(params["path"])
250
+ if ref_sr != sr:
251
+ return _err(request_id, -32000, f"Sample rates differ: {ref_sr} vs {sr}")
252
+ return _ok(request_id, analysis.alignment(ref, target, sr, float(params.get("maxLagMs", 20))))
253
+ except Exception as exc: # noqa: BLE001
254
+ return _err(request_id, -32000, f"Alignment failed: {exc}")
255
+
256
+
257
+ def handle_verify_edit(request_id: Any, params: dict) -> str:
258
+ """
259
+ Checks on the result of an edit: `path` (after) against `referencePath` (before).
260
+ Compensated null (delay, gain, polarity, residual) and a discontinuity check.
261
+
262
+ params: referencePath, path, expect? ("identical" | "changed"), timeScale? (1 = timing kept),
263
+ maxLagMs? (default 20)
264
+ """
265
+ import verify
266
+
267
+ for key in ("referencePath", "path"):
268
+ error = _check_file(request_id, params, key)
269
+ if error:
270
+ return error
271
+ try:
272
+ import analysis
273
+
274
+ ref, ref_sr = analysis.load_audio(params["referencePath"])
275
+ edited, sr = analysis.load_audio(params["path"])
276
+ if ref_sr != sr:
277
+ return _err(request_id, -32000, f"Sample rates differ: {ref_sr} vs {sr}")
278
+ result = verify.verify_edit(ref, edited, sr, expect=str(params.get("expect", "identical")),
279
+ time_scale=float(params.get("timeScale", 1.0)),
280
+ max_lag_ms=float(params.get("maxLagMs", 20)))
281
+ return _ok(request_id, result)
282
+ except Exception as exc: # noqa: BLE001 - a JSON-RPC handler reports any failure to the caller instead of crashing the sidecar
283
+ return _err(request_id, -32000, f"Verification failed: {exc}")
284
+
285
+
286
+ def handle_transients(request_id: Any, params: dict) -> str:
287
+ """
288
+ Transient (onset) times of a file with strength, dominant band and tempo.
289
+
290
+ params: path, startTime?, endTime?, band? (full|low|mid|high), sensitivity? (0..1),
291
+ minGapMs? (default 50)
292
+ """
293
+ import analysis
294
+
295
+ error = _check_file(request_id, params, "path")
296
+ if error:
297
+ return error
298
+ try:
299
+ start = float(params.get("startTime", 0))
300
+ audio, sr = analysis.load_audio(params["path"], start, params.get("endTime"))
301
+ if audio.shape[1] < int(0.1 * sr):
302
+ return _err(request_id, -32000, "Audio too short: need at least 0.1 s")
303
+ result = analysis.transients(audio, sr, str(params.get("band", "full")),
304
+ float(params.get("sensitivity", 0.5)), float(params.get("minGapMs", 50)))
305
+ if start:
306
+ for item in result["transients"]:
307
+ item["time"] = round(item["time"] + start, 4)
308
+ result["sampleRate"] = sr
309
+ return _ok(request_id, result)
310
+ except ValueError as exc:
311
+ return _err(request_id, -32602, str(exc))
312
+ except Exception as exc: # noqa: BLE001
313
+ return _err(request_id, -32000, f"Transient detection failed: {exc}")
314
+
315
+
316
+ def _load_window(request_id: Any, params: dict, min_seconds: float) -> tuple[Any, int, float] | str:
317
+ """Read params.path over [startTime, endTime), or an error response."""
318
+ import analysis
319
+
320
+ error = _check_file(request_id, params, "path")
321
+ if error:
322
+ return error
323
+ start = float(params.get("startTime", 0))
324
+ audio, sr = analysis.load_audio(params["path"], start, params.get("endTime"))
325
+ if audio.shape[1] < int(min_seconds * sr):
326
+ return _err(request_id, -32000, f"Audio too short: need at least {min_seconds:g} s")
327
+ return audio, sr, start
328
+
329
+
330
+ def _shift(span: dict | None, offset: float) -> None:
331
+ if span and offset:
332
+ for key in ("start", "end"):
333
+ if key in span:
334
+ span[key] = round(span[key] + offset, 1)
335
+
336
+
337
+ def handle_loudness_curve(request_id: Any, params: dict) -> str:
338
+ """
339
+ Short-term and momentary loudness over time (bucketed), loudest/quietest
340
+ windows and spans over a target. Times are in the file's own seconds plus
341
+ startTime.
342
+
343
+ params: path, startTime?, endTime?, targetLufs?, toleranceLu? (default 1)
344
+ """
345
+ import mixcheck
346
+
347
+ try:
348
+ loaded = _load_window(request_id, params, 0.4)
349
+ if isinstance(loaded, str):
350
+ return loaded
351
+ audio, sr, start = loaded
352
+ target = params.get("targetLufs")
353
+ result = mixcheck.loudness_curve(audio, sr, None if target is None else float(target),
354
+ float(params.get("toleranceLu", 1.0)))
355
+ for key in ("loudest", "quietest"):
356
+ _shift(result.get(key), start)
357
+ for span in result.get("overTarget", []):
358
+ _shift(span, start)
359
+ result["startTime"] = start
360
+ return _ok(request_id, result)
361
+ except Exception as exc: # noqa: BLE001
362
+ return _err(request_id, -32000, f"Loudness curve failed: {exc}")
363
+
364
+
365
+ def handle_stereo_bands(request_id: Any, params: dict) -> str:
366
+ """
367
+ Stereo correlation, width and phase problems per frequency band.
368
+
369
+ params: path, startTime?, endTime?
370
+ """
371
+ import mixcheck
372
+
373
+ try:
374
+ loaded = _load_window(request_id, params, 0.5)
375
+ if isinstance(loaded, str):
376
+ return loaded
377
+ audio, sr, start = loaded
378
+ result = mixcheck.band_correlation(audio, sr)
379
+ result["startTime"] = start
380
+ return _ok(request_id, result)
381
+ except ValueError as exc:
382
+ return _err(request_id, -32602, str(exc))
383
+ except Exception as exc: # noqa: BLE001
384
+ return _err(request_id, -32000, f"Stereo band analysis failed: {exc}")
385
+
386
+
387
+ def handle_masking(request_id: Any, params: dict) -> str:
388
+ """
389
+ Where 2-8 tracks (renders of the same time range) compete for the same
390
+ third-octave bands, with which one to cut.
391
+
392
+ params: tracks: [{name, path}]
393
+ """
394
+ import analysis
395
+ import mixcheck
396
+
397
+ tracks = params.get("tracks")
398
+ if not isinstance(tracks, list) or not 2 <= len(tracks) <= 8:
399
+ return _err(request_id, -32602, "tracks must list 2 to 8 {name, path}")
400
+ loaded = []
401
+ rate = None
402
+ try:
403
+ for t in tracks:
404
+ error = _check_file(request_id, t, "path")
405
+ if error:
406
+ return error
407
+ audio, sr = analysis.load_audio(t["path"])
408
+ if rate is not None and sr != rate:
409
+ return _err(request_id, -32000, f"Sample rates differ: {rate} vs {sr}")
410
+ rate = sr
411
+ loaded.append((str(t.get("name") or os.path.basename(t["path"])), audio))
412
+ return _ok(request_id, mixcheck.masking(loaded, rate))
413
+ except ValueError as exc:
414
+ return _err(request_id, -32602, str(exc))
415
+ except Exception as exc: # noqa: BLE001
416
+ return _err(request_id, -32000, f"Masking analysis failed: {exc}")
417
+
418
+
419
+ def handle_separate(request_id: Any, params: dict) -> str:
420
+ """
421
+ Split a file into stems with Demucs (opt-in install), written as WAVs to outDir.
422
+
423
+ params: path, outDir, model? (htdemucs|htdemucs_6s), stems? [names], startTime?, endTime?, maxSeconds?
424
+ """
425
+ import separation
426
+
427
+ error = _check_file(request_id, params, "path")
428
+ if error:
429
+ return error
430
+ if not params.get("outDir"):
431
+ return _err(request_id, -32602, "Missing required param: outDir")
432
+ try:
433
+ return _ok(request_id, separation.separate(
434
+ params["path"], params["outDir"], str(params.get("model", "htdemucs")), params.get("stems"),
435
+ float(params.get("startTime", 0)), params.get("endTime"), params.get("maxSeconds")))
436
+ except ValueError as exc:
437
+ return _err(request_id, -32602, str(exc))
438
+ except RuntimeError as exc:
439
+ return _err(request_id, -32000, str(exc))
440
+ except Exception as exc: # noqa: BLE001
441
+ return _err(request_id, -32000, f"Separation failed: {exc}")
442
+
443
+
444
+ def handle_separation_status(request_id: Any, params: dict) -> str:
445
+ """Whether Demucs is installed, so a caller can refuse before rendering anything."""
446
+ import separation
447
+
448
+ return _ok(request_id, {"installed": separation.is_installed(), "message": separation.NOT_INSTALLED})
449
+
450
+
451
+ def handle_chords(request_id: Any, params: dict) -> str:
452
+ """
453
+ Chord segments of a file with lv-chordia (opt-in install), Harte labels.
454
+
455
+ params: path, startTime?, endTime? (seconds into the file), vocabulary? (submission|ismir2017|full)
456
+ """
457
+ import chords
458
+
459
+ error = _check_file(request_id, params, "path")
460
+ if error:
461
+ return error
462
+ try:
463
+ end = params.get("endTime")
464
+ return _ok(request_id, chords.recognize(
465
+ params["path"], float(params.get("startTime", 0)), None if end is None else float(end),
466
+ str(params.get("vocabulary", "submission"))))
467
+ except ValueError as exc:
468
+ return _err(request_id, -32602, str(exc))
469
+ except RuntimeError as exc:
470
+ return _err(request_id, -32000, str(exc))
471
+ except Exception as exc: # noqa: BLE001
472
+ return _err(request_id, -32000, f"Chord recognition failed: {exc}")
473
+
474
+
475
+ def handle_chords_status(request_id: Any, params: dict) -> str:
476
+ """Whether the chord model is installed, so a caller can pick the template engine instead."""
477
+ import chords
478
+
479
+ return _ok(request_id, chords.status())
480
+
481
+
482
+ def handle_chroma(request_id: Any, params: dict) -> str:
483
+ """
484
+ Pitch-class energy of a file (C first, largest = 1), for key detection.
485
+
486
+ params: path, startTime?, endTime? (seconds into the file), segments? ([start, end] pairs in
487
+ seconds from startTime: each segment's chroma too, for chord detection)
488
+ """
489
+ import analysis
490
+
491
+ error = _check_file(request_id, params, "path")
492
+ if error:
493
+ return error
494
+ try:
495
+ audio, sr = analysis.load_audio(params["path"], float(params.get("startTime", 0)), params.get("endTime"))
496
+ if audio.shape[1] < int(0.5 * sr):
497
+ return _err(request_id, -32000, "Audio too short: need at least 0.5 s")
498
+ segments = params.get("segments")
499
+ if segments is not None and (not isinstance(segments, list) or len(segments) > 4000 or not all(
500
+ isinstance(x, list) and len(x) == 2 and all(isinstance(v, (int, float)) for v in x) for x in segments)):
501
+ return _err(request_id, -32602, "segments must be a list of at most 4000 [start, end] pairs")
502
+ return _ok(request_id, analysis.chroma(audio, sr, segments=segments))
503
+ except Exception as exc: # noqa: BLE001
504
+ return _err(request_id, -32000, f"Chroma failed: {exc}")
505
+
506
+
507
+ def handle_voice_sketch(request_id: Any, params: dict) -> str:
508
+ """Transcribe a bounded file range. Never writes files or changes REAPER."""
509
+ import math
510
+ import soundfile as sf
511
+ import analysis
512
+ import voice_sketch
513
+
514
+ error = _check_file(request_id, params, "path")
515
+ if error:
516
+ return error
517
+ try:
518
+ info = sf.info(params["path"])
519
+ start = float(params.get("startTime", 0))
520
+ end = float(params.get("endTime", info.duration))
521
+ if not math.isfinite(start) or not math.isfinite(end) or start < 0 or end <= start:
522
+ raise ValueError("Require finite 0 <= startTime < endTime")
523
+ if end - start > voice_sketch.MAX_SECONDS:
524
+ raise ValueError(f"Select at most {voice_sketch.MAX_SECONDS} seconds per call")
525
+ if end > info.duration + 1 / info.samplerate:
526
+ raise ValueError("endTime is beyond the end of the audio file")
527
+ audio, sr = analysis.load_audio(params["path"], start, end)
528
+ result = voice_sketch.transcribe(
529
+ audio, sr, mode=str(params.get("mode", "auto")),
530
+ bpm=None if params.get("bpm") is None else float(params["bpm"]),
531
+ grid=float(params.get("grid", 0.25)), pitch_min=int(params.get("pitchMin", 36)),
532
+ pitch_max=int(params.get("pitchMax", 84)), min_note_ms=float(params.get("minNoteMs", 80)),
533
+ tempo_change_threshold=float(params.get("tempoChangeThreshold", 0.2)))
534
+ result["startTime"] = start
535
+ result["tempo"]["originSeconds"] += start
536
+ for segment in result["tempo"]["segments"]:
537
+ segment["time"] = round(segment["time"] + start, 4)
538
+ for note in result["melody"] + result["drums"]:
539
+ note["time"] = round(note["time"] + start, 4)
540
+ return _ok(request_id, result)
541
+ except ValueError as exc:
542
+ return _err(request_id, -32602, str(exc))
543
+ except Exception as exc: # noqa: BLE001 - decoder/DSP failures must fail only this RPC
544
+ return _err(request_id, -32000, f"Voice transcription failed: {exc}")
545
+
546
+
547
+ def handle_voice_render(request_id: Any, params: dict) -> str:
548
+ """Render generated MIDI parts, never mutate a REAPER project."""
549
+ try:
550
+ import voice_render
551
+ return _ok(request_id, voice_render.render(params))
552
+ except ValueError as exc:
553
+ return _err(request_id, -32602, str(exc))
554
+ except Exception as exc: # noqa: BLE001 - native-library failures must not stop the sidecar
555
+ return _err(request_id, -32000, f"Voice arrangement preview failed: {exc}")
556
+
557
+
558
+ # JSON-RPC method names must stay in sync with the TypeScript client.
559
+ # When adding a method here, also reference it from apps/reaper-mcp-server/src/sidecar.ts.
560
+ METHODS = {
561
+ "analyze": handle_analyze,
562
+ "analyze_audio": handle_analyze_audio,
563
+ "align": handle_align,
564
+ "verify_edit": handle_verify_edit,
565
+ "transients": handle_transients,
566
+ "chroma": handle_chroma,
567
+ "loudness_curve": handle_loudness_curve,
568
+ "stereo_bands": handle_stereo_bands,
569
+ "masking": handle_masking,
570
+ "separate": handle_separate,
571
+ "separation_status": handle_separation_status,
572
+ "chords": handle_chords,
573
+ "chords_status": handle_chords_status,
574
+ "transcribe_voice_sketch": handle_voice_sketch,
575
+ "render_voice_arrangement": handle_voice_render,
576
+ }
577
+
578
+
579
+ def dispatch(raw: str) -> str:
580
+ """Parse a JSON-RPC request string and dispatch to the appropriate method."""
581
+ request_id = None
582
+ try:
583
+ req = json.loads(raw)
584
+ request_id = req.get("id")
585
+ method = req.get("method")
586
+ params = req.get("params", {})
587
+
588
+ if method not in METHODS:
589
+ return _err(request_id, -32601, f"Method not found: {method}")
590
+
591
+ return METHODS[method](request_id, params)
592
+
593
+ except json.JSONDecodeError as exc:
594
+ return _err(request_id, -32700, f"Parse error: {exc}")
595
+ except Exception as exc: # noqa: BLE001
596
+ return _err(request_id, -32603, f"Internal error: {exc}")
597
+
598
+
599
+ # ---------------------------------------------------------------------------
600
+ # Main loop
601
+ # ---------------------------------------------------------------------------
602
+
603
+ def main() -> None:
604
+ # Disable output buffering so responses are delivered immediately
605
+ sys.stdout = os.fdopen(sys.stdout.fileno(), "w", buffering=1)
606
+
607
+ # Log startup to stderr (visible in MCP server logs)
608
+ print(f"[reaper-mcp-sidecar] Starting — Python {sys.version}", file=sys.stderr, flush=True)
609
+
610
+ for line in sys.stdin:
611
+ line = line.strip()
612
+ if not line:
613
+ continue
614
+
615
+ response = dispatch(line)
616
+ print(response, flush=True)
617
+
618
+ print("[reaper-mcp-sidecar] stdin closed, exiting", file=sys.stderr, flush=True)
619
+
620
+
621
+ if __name__ == "__main__":
622
+ main()