@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,464 @@
1
+ """
2
+ pytest unit tests for the Python sidecar server.
3
+
4
+ Strategy: mock audiobox_aesthetics.infer.initialize_predictor and
5
+ soundfile.info so tests run without the actual model or audio files.
6
+
7
+ Run:
8
+ pytest apps/reaper-mcp-server/sidecar/tests/test_server.py -v
9
+
10
+ Or with the venv installed by setup-sidecar:
11
+ ~/.reaper-mcp/sidecar-venv/bin/python -m pytest ...
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ import os
19
+ from pathlib import Path
20
+ from unittest.mock import MagicMock, patch, PropertyMock
21
+
22
+ # Add the sidecar directory to sys.path so we can import server directly
23
+ sys.path.insert(0, str(Path(__file__).parent.parent))
24
+
25
+ import server # noqa: E402 (must come after sys.path manipulation)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def parse_response(raw: str) -> dict:
33
+ return json.loads(raw)
34
+
35
+
36
+ def make_mock_predictor(scores: dict | None = None) -> MagicMock:
37
+ """Return a mock predictor whose .forward() returns a one-element list."""
38
+ scores = scores or {"CE": 5.15, "CU": 5.78, "PC": 2.15, "PQ": 7.22}
39
+ mock = MagicMock()
40
+ mock.forward.return_value = [scores]
41
+ return mock
42
+
43
+
44
+ def make_sf_info(duration: float = 5.0) -> MagicMock:
45
+ """Return a mock soundfile.info result."""
46
+ info = MagicMock()
47
+ info.duration = duration
48
+ return info
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Reset predictor state between tests
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def setup_function():
56
+ """Reset the global predictor cache before each test."""
57
+ server._predictor = None
58
+ server._predictor_error = None
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Test 1: Valid WAV input returns 4 float scores with correct JSON-RPC shape
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def test_analyze_success(tmp_path):
66
+ wav = tmp_path / "test.wav"
67
+ wav.write_bytes(b"") # file must exist; duration is mocked
68
+
69
+ mock_pred = make_mock_predictor()
70
+ # Pre-populate the predictor cache so server._get_predictor() returns the
71
+ # mock without ever importing audiobox_aesthetics (avoids requiring the
72
+ # package to be installed for unit tests).
73
+ server._predictor = mock_pred
74
+
75
+ with patch("server._get_audio_duration", return_value=5.0), \
76
+ patch("server._load_clip", return_value=("WAV", 44100)):
77
+
78
+ raw = server.dispatch(json.dumps({
79
+ "jsonrpc": "2.0",
80
+ "id": 1,
81
+ "method": "analyze",
82
+ "params": {"path": str(wav), "startTime": 0, "endTime": 5.0},
83
+ }))
84
+
85
+ resp = parse_response(raw)
86
+ assert resp["jsonrpc"] == "2.0"
87
+ assert resp["id"] == 1
88
+ assert "result" in resp
89
+ assert "error" not in resp
90
+
91
+ result = resp["result"]
92
+ assert isinstance(result["CE"], float)
93
+ assert isinstance(result["CU"], float)
94
+ assert isinstance(result["PC"], float)
95
+ assert isinstance(result["PQ"], float)
96
+ assert result["modelVersion"] == "facebook/audiobox-aesthetics"
97
+ assert result["durationSeconds"] == 5.0
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Test 2: initialize_predictor ImportError → JSON-RPC error
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def test_model_not_found_import_error(tmp_path):
105
+ wav = tmp_path / "test.wav"
106
+ wav.write_bytes(b"")
107
+
108
+ with patch("server._get_audio_duration", return_value=5.0), \
109
+ patch.dict("sys.modules", {"audiobox_aesthetics": None, "audiobox_aesthetics.infer": None}):
110
+
111
+ # Force the import inside _ensure_predictor to fail by patching the module
112
+ import builtins
113
+ real_import = builtins.__import__
114
+
115
+ def mock_import(name, *args, **kwargs):
116
+ if "audiobox_aesthetics" in name:
117
+ raise ImportError("No module named 'audiobox_aesthetics'")
118
+ return real_import(name, *args, **kwargs)
119
+
120
+ with patch("builtins.__import__", side_effect=mock_import):
121
+ raw = server.dispatch(json.dumps({
122
+ "jsonrpc": "2.0",
123
+ "id": 2,
124
+ "method": "analyze",
125
+ "params": {"path": str(wav)},
126
+ }))
127
+
128
+ resp = parse_response(raw)
129
+ assert "error" in resp
130
+ assert "audiobox-aesthetics" in resp["error"]["message"].lower() or \
131
+ "not installed" in resp["error"]["message"].lower() or \
132
+ "No module named" in resp["error"]["message"]
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Test 3: Audio file not found → JSON-RPC error response
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def test_audio_file_not_found():
140
+ raw = server.dispatch(json.dumps({
141
+ "jsonrpc": "2.0",
142
+ "id": 3,
143
+ "method": "analyze",
144
+ "params": {"path": "/nonexistent/definitely/not/there.wav"},
145
+ }))
146
+
147
+ resp = parse_response(raw)
148
+ assert "error" in resp
149
+ assert "not found" in resp["error"]["message"].lower()
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Test 4: Audio too short (<0.5s) → JSON-RPC error with duration in message
154
+ # ---------------------------------------------------------------------------
155
+
156
+ def test_audio_too_short(tmp_path):
157
+ wav = tmp_path / "short.wav"
158
+ wav.write_bytes(b"")
159
+
160
+ with patch("server._get_audio_duration", return_value=0.3):
161
+ raw = server.dispatch(json.dumps({
162
+ "jsonrpc": "2.0",
163
+ "id": 4,
164
+ "method": "analyze",
165
+ "params": {"path": str(wav), "startTime": 0, "endTime": 0.3},
166
+ }))
167
+
168
+ resp = parse_response(raw)
169
+ assert "error" in resp
170
+ msg = resp["error"]["message"]
171
+ assert "0.30" in msg or "0.3" in msg
172
+ assert "short" in msg.lower() or "minimum" in msg.lower()
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Test 5: start_time/end_time passed through to predictor
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def test_start_end_time_passthrough(tmp_path):
180
+ wav = tmp_path / "long.wav"
181
+ wav.write_bytes(b"")
182
+
183
+ mock_pred = make_mock_predictor()
184
+ server._predictor = mock_pred # bypass import; see test_analyze_success
185
+
186
+ with patch("server._get_audio_duration", return_value=30.0), \
187
+ patch("server._load_clip", return_value=("WAV", 44100)) as load_clip:
188
+
189
+ raw = server.dispatch(json.dumps({
190
+ "jsonrpc": "2.0",
191
+ "id": 5,
192
+ "method": "analyze",
193
+ "params": {"path": str(wav), "startTime": 10.0, "endTime": 15.0},
194
+ }))
195
+
196
+ resp = parse_response(raw)
197
+ assert "result" in resp
198
+
199
+ # The clip is decoded here and handed to the predictor as a tensor
200
+ load_clip.assert_called_once_with(str(wav), 10.0, 15.0)
201
+ call_args = mock_pred.forward.call_args[0][0]
202
+ assert call_args[0] == {"path": "WAV", "sample_rate": 44100}
203
+ result = resp["result"]
204
+ assert result["durationSeconds"] == 5.0 # 15.0 - 10.0
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Test 6: Unknown method → -32601 error
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def test_unknown_method():
212
+ raw = server.dispatch(json.dumps({
213
+ "jsonrpc": "2.0",
214
+ "id": 6,
215
+ "method": "nonexistent",
216
+ "params": {},
217
+ }))
218
+
219
+ resp = parse_response(raw)
220
+ assert "error" in resp
221
+ assert resp["error"]["code"] == -32601
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Test 7: Malformed JSON → -32700 parse error
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def test_parse_error():
229
+ raw = server.dispatch("{not valid json")
230
+ resp = parse_response(raw)
231
+ assert "error" in resp
232
+ assert resp["error"]["code"] == -32700
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Test 8: Missing required path param
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def test_missing_path_param():
240
+ raw = server.dispatch(json.dumps({
241
+ "jsonrpc": "2.0",
242
+ "id": 8,
243
+ "method": "analyze",
244
+ "params": {},
245
+ }))
246
+
247
+ resp = parse_response(raw)
248
+ assert "error" in resp
249
+ assert "path" in resp["error"]["message"].lower()
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # _load_clip decodes the requested slice without torchaudio
254
+ # ---------------------------------------------------------------------------
255
+
256
+ def test_load_clip_slices_stereo_wav(tmp_path):
257
+ import numpy as np
258
+ import numpy as np
259
+ import soundfile as sf
260
+
261
+ wav = tmp_path / "stereo.wav"
262
+ sf.write(str(wav), np.zeros((44100 * 3, 2), dtype="float32"), 44100)
263
+
264
+ clip, sample_rate = server._load_clip(str(wav), 1.0, 2.5)
265
+
266
+ assert sample_rate == 44100
267
+ assert tuple(clip.shape) == (2, int(44100 * 1.5))
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # normalizeLufs: the clip reaches the model at the target loudness
272
+ # ---------------------------------------------------------------------------
273
+
274
+ def _write_sine(path, dbfs: float, seconds: float = 3.0, sr: int = 44100, delay: int = 0, sign: float = 1.0):
275
+ import numpy as np
276
+ import numpy as np
277
+ import soundfile as sf
278
+
279
+ t = np.arange(int(seconds * sr)) / sr
280
+ rng = np.random.default_rng(11)
281
+ wave = 10 ** (dbfs / 20) * (np.sin(2 * np.pi * 1000 * t) + 0.3 * rng.standard_normal(t.size))
282
+ wave = np.concatenate([np.zeros(delay), wave])[: t.size] * sign
283
+ sf.write(str(path), np.stack([wave, wave], axis=1).astype("float32"), sr)
284
+
285
+
286
+ def test_analyze_normalize_scales_clip_before_scoring(tmp_path):
287
+ import analysis
288
+
289
+ wav = tmp_path / "quiet.wav"
290
+ _write_sine(wav, -30)
291
+ mock_pred = make_mock_predictor()
292
+ server._predictor = mock_pred
293
+
294
+ raw = server.dispatch(json.dumps({
295
+ "jsonrpc": "2.0", "id": 6, "method": "analyze",
296
+ "params": {"path": str(wav), "normalizeLufs": -18},
297
+ }))
298
+
299
+ result = parse_response(raw)["result"]
300
+ scored = mock_pred.forward.call_args[0][0][0]["path"].numpy()
301
+ assert abs(analysis.integrated_loudness(scored, 44100) - (-18.0)) < 0.2
302
+ assert result["normalizeLufs"] == -18.0
303
+ assert abs(result["gainDb"] - (-18.0 - result["inputLufs"])) < 0.01
304
+
305
+
306
+ def test_analyze_without_normalize_keeps_level(tmp_path):
307
+ wav = tmp_path / "quiet.wav"
308
+ _write_sine(wav, -30)
309
+ mock_pred = make_mock_predictor()
310
+ server._predictor = mock_pred
311
+
312
+ result = parse_response(server.dispatch(json.dumps({
313
+ "jsonrpc": "2.0", "id": 7, "method": "analyze", "params": {"path": str(wav)},
314
+ })))["result"]
315
+
316
+ assert result["gainDb"] == 0.0 and result["inputLufs"] is None
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # analyze_audio / align run end to end on real files, without the model
321
+ # ---------------------------------------------------------------------------
322
+
323
+ def test_analyze_audio_with_reference(tmp_path):
324
+ mix, ref = tmp_path / "mix.wav", tmp_path / "ref.wav"
325
+ _write_sine(mix, -14, seconds=6)
326
+ _write_sine(ref, -20, seconds=6)
327
+
328
+ resp = parse_response(server.dispatch(json.dumps({
329
+ "jsonrpc": "2.0", "id": 8, "method": "analyze_audio",
330
+ "params": {"path": str(mix), "referencePath": str(ref), "sectionSeconds": 3},
331
+ })))
332
+
333
+ result = resp["result"]
334
+ assert result["loudness"]["integratedLufs"] > -20
335
+ assert len(result["loudness"]["sections"]) == 2
336
+ assert abs(result["reference"]["loudnessDeltaLu"] - 6.0) < 0.5
337
+ assert server._predictor is None # no model load for offline analysis
338
+
339
+
340
+ def test_analyze_audio_missing_reference_is_an_error(tmp_path):
341
+ mix = tmp_path / "mix.wav"
342
+ _write_sine(mix, -14)
343
+ resp = parse_response(server.dispatch(json.dumps({
344
+ "jsonrpc": "2.0", "id": 9, "method": "analyze_audio",
345
+ "params": {"path": str(mix), "referencePath": "/nope/ref.wav"},
346
+ })))
347
+ assert "not found" in resp["error"]["message"]
348
+
349
+
350
+ def test_align_method_reports_delay_and_polarity(tmp_path):
351
+ ref, late = tmp_path / "ref.wav", tmp_path / "late.wav"
352
+ _write_sine(ref, -12)
353
+ _write_sine(late, -12, delay=37, sign=-1.0)
354
+
355
+ result = parse_response(server.dispatch(json.dumps({
356
+ "jsonrpc": "2.0", "id": 10, "method": "align",
357
+ "params": {"referencePath": str(ref), "path": str(late), "maxLagMs": 5},
358
+ })))["result"]
359
+
360
+ assert result["delaySamples"] == 37
361
+ assert result["polarityInverted"] is True
362
+
363
+
364
+ def test_transients_method_reports_onsets_in_file_time(tmp_path):
365
+ import numpy as np
366
+ import soundfile as sf
367
+
368
+ sr = 44100
369
+ x = np.zeros(sr * 4, dtype=np.float32)
370
+ for t in (1.0, 2.0, 3.0):
371
+ n = int(0.05 * sr)
372
+ x[int(t * sr):int(t * sr) + n] = 0.5 * np.exp(-np.arange(n) / sr * 80) * np.sign(np.sin(np.arange(n) * 0.3))
373
+ path = tmp_path / "clicks.wav"
374
+ sf.write(str(path), x, sr)
375
+
376
+ result = parse_response(server.dispatch(json.dumps({
377
+ "jsonrpc": "2.0", "id": 11, "method": "transients",
378
+ "params": {"path": str(path), "startTime": 1.5},
379
+ })))["result"]
380
+
381
+ times = [t["time"] for t in result["transients"]]
382
+ assert len(times) == 2
383
+ assert abs(times[0] - 2.0) < 0.005 and abs(times[1] - 3.0) < 0.005
384
+
385
+
386
+ def test_transients_method_refuses_an_unknown_band(tmp_path):
387
+ path = tmp_path / "tone.wav"
388
+ _write_sine(path, -12)
389
+ resp = parse_response(server.dispatch(json.dumps({
390
+ "jsonrpc": "2.0", "id": 12, "method": "transients",
391
+ "params": {"path": str(path), "band": "treble"},
392
+ })))
393
+ assert "band must be one of" in resp["error"]["message"]
394
+
395
+
396
+ def _call(method: str, params: dict) -> dict:
397
+ return parse_response(server.dispatch(json.dumps({"jsonrpc": "2.0", "id": 20, "method": method, "params": params})))
398
+
399
+
400
+ def test_loudness_curve_method_reports_windows_in_project_time(tmp_path):
401
+ import numpy as np
402
+ import soundfile as sf
403
+
404
+ sr = 44100
405
+ t = np.arange(sr * 12) / sr
406
+ level = np.where(t < 6, 10 ** (-30 / 20), 10 ** (-12 / 20))
407
+ x = (level * np.sin(2 * np.pi * 1000 * t)).astype("float32")
408
+ path = tmp_path / "song.wav"
409
+ sf.write(str(path), np.stack([x, x], axis=1), sr)
410
+
411
+ result = _call("loudness_curve", {"path": str(path), "startTime": 2, "targetLufs": -14})["result"]
412
+ assert result["loudest"]["start"] >= 6 + 0, "after the step, shifted by startTime"
413
+ assert result["overTarget"] and result["overTarget"][0]["start"] >= 5
414
+ assert result["startTime"] == 2
415
+
416
+
417
+ def test_stereo_bands_method_refuses_a_mono_file(tmp_path):
418
+ import numpy as np
419
+ import soundfile as sf
420
+
421
+ path = tmp_path / "mono.wav"
422
+ sf.write(str(path), (0.1 * np.random.default_rng(1).standard_normal(44100)).astype("float32"), 44100)
423
+ resp = _call("stereo_bands", {"path": str(path)})
424
+ assert "stereo" in resp["error"]["message"]
425
+
426
+
427
+ def test_masking_method_needs_two_to_eight_tracks_and_reports_conflicts(tmp_path):
428
+ a, b = tmp_path / "a.wav", tmp_path / "b.wav"
429
+ _write_sine(a, -12)
430
+ _write_sine(b, -13)
431
+ result = _call("masking", {"tracks": [{"name": "Gtr", "path": str(a)}, {"name": "Keys", "path": str(b)}]})["result"]
432
+ assert result["tracks"] == ["Gtr", "Keys"]
433
+ assert any(c["hz"] == 1000 for c in result["conflicts"])
434
+
435
+ assert "2 to 8" in _call("masking", {"tracks": [{"name": "Gtr", "path": str(a)}]})["error"]["message"]
436
+ missing = _call("masking", {"tracks": [{"name": "Gtr", "path": str(a)}, {"name": "X", "path": str(tmp_path / "nope.wav")}]})
437
+ assert "not found" in missing["error"]["message"]
438
+
439
+
440
+ def test_separate_method_refuses_an_unknown_stem_before_loading_anything(tmp_path):
441
+ path = tmp_path / "mix.wav"
442
+ _write_sine(path, -12)
443
+ resp = _call("separate", {"path": str(path), "outDir": str(tmp_path / "out"), "stems": ["kazoo"]})
444
+ assert "has no stem kazoo" in resp["error"]["message"]
445
+ assert not (tmp_path / "out").exists()
446
+
447
+
448
+ def test_separation_status_reports_a_missing_demucs_with_how_to_install(monkeypatch):
449
+ import separation
450
+
451
+ monkeypatch.setattr(separation, "is_installed", lambda: False)
452
+ resp = _call("separation_status", {})
453
+ assert resp["result"]["installed"] is False
454
+ assert "setup-sidecar --with-separation" in resp["result"]["message"]
455
+
456
+
457
+ def test_separate_method_says_how_to_install_when_demucs_is_missing(tmp_path, monkeypatch):
458
+ import separation
459
+
460
+ monkeypatch.setattr(separation, "is_installed", lambda: False)
461
+ path = tmp_path / "mix.wav"
462
+ _write_sine(path, -12)
463
+ resp = _call("separate", {"path": str(path), "outDir": str(tmp_path / "out")})
464
+ assert "setup-sidecar --with-separation" in resp["error"]["message"]