@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,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"]
|