@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.
- package/README.md +19 -570
- package/bridge-ui/mcp_bridge.html +129 -0
- package/bridge-ui/mcp_bridge.webmanifest +16 -0
- package/bridge-ui/mcp_bridge_icon_192.png +0 -0
- package/bridge-ui/mcp_bridge_icon_512.png +0 -0
- package/bridge-ui/mcp_bridge_sw.js +35 -0
- package/claude/agents/plugin-researcher.md +188 -0
- package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
- package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
- package/claude/knowledge/reference/lorekit-setup.md +36 -0
- package/claude/knowledge/reference/memory-protocol.md +377 -0
- package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
- package/claude/knowledge/reference/perceived-loudness.md +182 -0
- package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
- package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
- package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
- package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
- package/claude/knowledge/workflows/section-levels.md +138 -0
- package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
- package/claude/knowledge/workflows/transient-lighting.md +52 -0
- package/claude/skills/critique/SKILL.md +131 -0
- package/claude/skills/editing/SKILL.md +281 -0
- package/claude/skills/learn-plugin/SKILL.md +210 -0
- package/claude/skills/mastering/SKILL.md +151 -0
- package/claude/skills/mix-memory/SKILL.md +140 -0
- package/claude/skills/mixer/SKILL.md +253 -0
- package/claude/skills/new-extension/SKILL.md +324 -0
- package/main.js +7843 -1940
- package/package.json +17 -15
- package/reaper/CLAUDE.md +22 -4
- package/reaper/mcp_actions_lib.lua +375 -0
- package/reaper/mcp_analyzer.jsfx +27 -12
- package/reaper/mcp_arrange_lib.lua +949 -0
- package/reaper/mcp_bridge.lua +2321 -758
- package/reaper/mcp_edit_views_lib.lua +1378 -0
- package/reaper/mcp_envelopes_lib.lua +748 -0
- package/reaper/mcp_extensions_lib.lua +55 -0
- package/reaper/mcp_fxstate_lib.lua +144 -0
- package/reaper/mcp_gain_trace.jsfx +126 -0
- package/reaper/mcp_items_lib.lua +877 -0
- package/reaper/mcp_lufs_meter.jsfx +16 -13
- package/reaper/mcp_midi_lib.lua +555 -0
- package/reaper/mcp_mixing_lib.lua +1021 -0
- package/reaper/mcp_plan_lib.lua +801 -0
- package/reaper/mcp_prefs_lib.lua +127 -0
- package/reaper/mcp_project_lib.lua +1088 -0
- package/reaper/mcp_recording_lib.lua +595 -0
- package/reaper/mcp_render_lib.lua +492 -0
- package/reaper/mcp_snapshot_lib.lua +547 -120
- package/reaper/mcp_snapshot_next.lua +10 -4
- package/reaper/mcp_snapshot_prev.lua +11 -4
- package/reaper/mcp_snapshot_quick_save.lua +7 -32
- package/reaper/mcp_snapshots_ui_lib.lua +258 -0
- package/reaper/mcp_tracks_lib.lua +409 -0
- package/sidecar/analysis.py +661 -0
- package/sidecar/chords.py +96 -0
- package/sidecar/mixcheck.py +295 -0
- package/sidecar/requirements-chords.txt +6 -0
- package/sidecar/requirements-separation.txt +3 -0
- package/sidecar/requirements.txt +17 -0
- package/sidecar/separation.py +142 -0
- package/sidecar/server.py +622 -0
- package/sidecar/tests/test_analysis.py +249 -0
- package/sidecar/tests/test_chords.py +117 -0
- package/sidecar/tests/test_mixcheck.py +163 -0
- package/sidecar/tests/test_separation.py +73 -0
- package/sidecar/tests/test_server.py +464 -0
- package/sidecar/tests/test_verify.py +243 -0
- package/sidecar/tests/test_voice_render.py +268 -0
- package/sidecar/tests/test_voice_sketch.py +140 -0
- package/sidecar/verify.py +224 -0
- package/sidecar/voice_render.py +295 -0
- package/sidecar/voice_sketch.py +280 -0
- package/claude-agents/critique.md +0 -147
- package/claude-agents/editor.md +0 -184
- package/claude-agents/levels.md +0 -107
- package/claude-agents/mastering.md +0 -129
- package/claude-agents/mixer.md +0 -248
- package/claude-agents/preflight.md +0 -149
- package/claude-agents/producer.md +0 -159
- package/claude-agents/setup.md +0 -150
- package/claude-agents/stems.md +0 -135
- package/claude-rules/architecture.md +0 -39
- package/claude-rules/development.md +0 -54
- package/claude-rules/lua-bridge.md +0 -50
- package/claude-rules/testing.md +0 -42
- package/claude-skills/learn-plugin.md +0 -123
- package/knowledge/CLAUDE.md +0 -71
- package/knowledge/plugins/_template.md +0 -82
- package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
- package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
- package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
- package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
- package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
- package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
- package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
- package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
- package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
- package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
- package/knowledge/reference/perceived-loudness.md +0 -122
- package/reaper/mcp_snapshot_manager.lua +0 -1001
- /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
- /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()
|