@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,249 @@
1
+ """
2
+ Tests for sidecar/analysis.py against the EBU loudness reference signals.
3
+
4
+ Run with the sidecar venv (numpy, torch, soundfile):
5
+ ~/.reaper-mcp/sidecar-venv/bin/python -m pytest apps/reaper-mcp-server/sidecar/tests -q
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+
17
+ import analysis # noqa: E402
18
+
19
+ SR = 48000
20
+
21
+
22
+ def sine(freq: float, dbfs: float, seconds: float, channels: int = 2, sr: int = SR) -> np.ndarray:
23
+ t = np.arange(int(seconds * sr)) / sr
24
+ wave = (10 ** (dbfs / 20) * np.sin(2 * np.pi * freq * t)).astype(np.float32)
25
+ return np.tile(wave, (channels, 1))
26
+
27
+
28
+ def test_integrated_loudness_ebu3341_sine():
29
+ # EBU Tech 3341 case 1: stereo 1 kHz sine at -23 dBFS reads -23 LUFS
30
+ lufs = analysis.integrated_loudness(sine(1000, -23, 20), SR)
31
+ assert abs(lufs - (-23.0)) <= 0.3
32
+
33
+
34
+ def test_integrated_loudness_other_sample_rate():
35
+ lufs = analysis.integrated_loudness(sine(1000, -23, 10, sr=44100), 44100)
36
+ assert abs(lufs - (-23.0)) <= 0.3
37
+
38
+
39
+ def test_integrated_loudness_silence_is_minus_inf():
40
+ assert analysis.integrated_loudness(np.zeros((2, SR * 2), dtype=np.float32), SR) == float("-inf")
41
+
42
+
43
+ def test_loudness_range_ebu3342_two_levels():
44
+ # EBU Tech 3342 case 1: 20 s at -20 dBFS then 20 s at -30 dBFS → LRA 10 LU
45
+ audio = np.concatenate([sine(1000, -20, 20), sine(1000, -30, 20)], axis=1)
46
+ lra = analysis.loudness_range(audio, SR)
47
+ assert lra is not None and abs(lra - 10.0) <= 1.0
48
+
49
+
50
+ def test_true_peak_exceeds_sample_peak():
51
+ # fs/4 sine at 45° phase: every sample sits at 0.707 of the real peak
52
+ n = np.arange(SR)
53
+ wave = np.sin(np.pi / 2 * n + np.pi / 4).astype(np.float32) * 0.5
54
+ audio = np.tile(wave, (2, 1))
55
+ sample_peak = float(np.abs(audio).max())
56
+ tp = analysis.true_peak(audio, SR)
57
+ assert tp > sample_peak * 1.2
58
+
59
+
60
+ def test_reference_treble_delta_positive_when_brighter():
61
+ rng = np.random.default_rng(1)
62
+ noise = rng.standard_normal((2, SR * 6)).astype(np.float32) * 0.05
63
+ # The "mix" adds strong high-frequency content on top of the same noise
64
+ brighter = noise + sine(9000, -12, 6)
65
+ result = analysis.compare_to_reference(brighter, SR, noise, SR)
66
+ # Band figures are shares of total energy, so more treble means less of the rest
67
+ assert result["bandDeltaDb"]["brilliance"] > 0.5
68
+ assert result["bandDeltaDb"]["bass"] < 0
69
+ assert result["loudnessDeltaLu"] > 0
70
+ top = result["largestDifferences"][0]
71
+ assert 8000 <= top["hz"] <= 11000 and top["deltaDb"] > 6
72
+
73
+
74
+ def test_alignment_detects_delay_and_inverted_polarity():
75
+ rng = np.random.default_rng(7)
76
+ ref = rng.standard_normal((1, SR)).astype(np.float32)
77
+ delayed = np.zeros_like(ref)
78
+ delayed[:, 37:] = -ref[:, :-37]
79
+ result = analysis.alignment(ref, delayed, SR, max_lag_ms=5)
80
+ assert result["delaySamples"] == 37
81
+ assert result["polarityInverted"] is True
82
+ assert result["correlation"] > 0.9
83
+
84
+
85
+ def test_alignment_reports_negative_lag_when_target_is_early():
86
+ rng = np.random.default_rng(3)
87
+ ref = rng.standard_normal((2, SR)).astype(np.float32)
88
+ early = np.zeros_like(ref)
89
+ early[:, :-12] = ref[:, 12:]
90
+ result = analysis.alignment(ref, early, SR, max_lag_ms=5)
91
+ assert result["delaySamples"] == -12
92
+ assert result["polarityInverted"] is False
93
+
94
+
95
+ def test_normalize_to_lufs_hits_target():
96
+ audio = sine(1000, -30, 5)
97
+ out, measured, gain = analysis.normalize_to_lufs(audio, SR, -18.0)
98
+ assert abs(measured - (-30.0)) <= 0.3
99
+ assert abs(gain - 12.0) <= 0.3
100
+ assert abs(analysis.integrated_loudness(out, SR) - (-18.0)) <= 0.1
101
+
102
+
103
+ def test_normalize_leaves_silence_alone():
104
+ silent = np.zeros((2, SR), dtype=np.float32)
105
+ out, measured, gain = analysis.normalize_to_lufs(silent, SR, -18.0)
106
+ assert measured is None and gain == 0.0 and out is silent
107
+
108
+
109
+ def test_analyze_reports_sections_and_resonance():
110
+ rng = np.random.default_rng(5)
111
+ audio = rng.standard_normal((2, SR * 8)).astype(np.float32) * 0.05 + sine(3150, -14, 8)
112
+ result = analysis.analyze(audio, SR, section_s=4)
113
+ assert len(result["loudness"]["sections"]) == 2
114
+ hzs = [r["hz"] for r in result["spectrum"]["resonances"]]
115
+ assert any(abs(h - 3150) < 40 for h in hzs)
116
+
117
+
118
+ def test_alignment_names_the_silent_signal():
119
+ ref = sine(1000, -20, 1, channels=1)
120
+ result = analysis.alignment(ref, np.zeros_like(ref), SR)
121
+ assert result["delaySamples"] is None
122
+ assert result["error"].startswith("The track is silent")
123
+
124
+
125
+ def test_sibilance_on_silence_has_the_same_keys():
126
+ power, freqs = analysis._stft_power(np.zeros((2, SR * 2), dtype=np.float32), SR)
127
+ assert analysis.sibilance_hint(power, freqs, 2048 / SR)["spikeCount"] == 0
128
+
129
+
130
+ def test_sibilance_reports_each_burst_once():
131
+ rng = np.random.default_rng(2)
132
+ audio = sine(200, -20, 6)
133
+ for start in (1.0, 3.0): # two 0.3 s hiss bursts
134
+ i = int(start * SR)
135
+ audio[:, i:i + int(0.3 * SR)] += rng.standard_normal((2, int(0.3 * SR))).astype(np.float32) * 0.3
136
+ power, freqs = analysis._stft_power(audio, SR)
137
+ hint = analysis.sibilance_hint(power, freqs, 2048 / SR)
138
+ assert hint["spikeCount"] == 2
139
+ assert [round(t) for t in hint["spikeSeconds"]] == [1, 3]
140
+
141
+
142
+ def test_chunked_k_weighting_and_true_peak_match_one_piece(monkeypatch):
143
+ rng = np.random.default_rng(11)
144
+ audio = (rng.standard_normal((2, SR * 7)) * 0.1).astype(np.float32)
145
+ whole_kw = analysis.k_weight(audio, SR)
146
+ whole_tp = analysis.true_peak(audio, SR)
147
+ monkeypatch.setattr(analysis, "CHUNK_S", 2.0) # 4 chunks, the last one short
148
+ assert np.max(np.abs(analysis.k_weight(audio, SR) - whole_kw)) < 1e-9
149
+ assert abs(analysis.true_peak(audio, SR) - whole_tp) < 1e-3
150
+
151
+
152
+ def drum_loop(seconds: float = 8.0, sr: int = SR) -> tuple[np.ndarray, list[float], list[float]]:
153
+ """A 60 Hz kick on every beat at 120 BPM and a hi-hat (high-passed noise) on every off-beat."""
154
+ rng = np.random.default_rng(0)
155
+ x = rng.normal(0, 0.002, int(seconds * sr)).astype(np.float32)
156
+ kicks, hats = [], []
157
+ for k in range(int((seconds - 0.5) / 0.5)):
158
+ s = int((0.25 + 0.5 * k) * sr)
159
+ t = np.arange(int(0.2 * sr)) / sr
160
+ x[s:s + t.size] += (0.8 * np.sin(2 * np.pi * 60 * t) * np.exp(-t * 25)).astype(np.float32)
161
+ h = s + int(0.25 * sr)
162
+ n = int(0.03 * sr)
163
+ hat = np.diff(rng.normal(0, 1, n + 3), 3) / 8 # third difference: energy above ~5 kHz
164
+ x[h:h + n] += (0.3 * hat * np.exp(-np.arange(n) / sr * 150)).astype(np.float32)
165
+ kicks.append(s / sr)
166
+ hats.append(h / sr)
167
+ return np.vstack([x, x]), kicks, hats
168
+
169
+
170
+ def test_transients_finds_kicks_and_hats_with_their_bands_and_tempo():
171
+ audio, kicks, hats = drum_loop()
172
+ result = analysis.transients(audio, SR)
173
+
174
+ found = result["transients"]
175
+ assert result["count"] == len(kicks) + len(hats)
176
+ for expected, band in [*((t, "low") for t in kicks), *((t, "high") for t in hats)]:
177
+ nearest = min(found, key=lambda f: abs(f["time"] - expected))
178
+ assert abs(nearest["time"] - expected) < 0.005
179
+ assert nearest["band"] == band
180
+ assert 0 < nearest["strength"] <= 1
181
+ assert abs(result["tempo"]["bpm"] - 120) < 1
182
+
183
+
184
+ def test_transients_in_one_band_skip_the_other_instruments():
185
+ audio, kicks, _ = drum_loop()
186
+ low = analysis.transients(audio, SR, band="low")
187
+ assert low["count"] == len(kicks)
188
+
189
+
190
+ def test_transients_lower_sensitivity_never_finds_more():
191
+ audio, _, _ = drum_loop()
192
+ loose = analysis.transients(audio, SR, sensitivity=0.9)["count"]
193
+ strict = analysis.transients(audio, SR, sensitivity=0.1)["count"]
194
+ assert strict <= loose
195
+
196
+
197
+ def test_transients_of_silence_is_empty():
198
+ result = analysis.transients(np.zeros((2, SR * 2), dtype=np.float32), SR)
199
+ assert result["count"] == 0
200
+ assert result["tempo"]["bpm"] is None
201
+
202
+
203
+ def _tones(freqs_hz, seconds=3.0, sr=44100):
204
+ t = np.arange(int(seconds * sr)) / sr
205
+ return np.stack([sum(np.sin(2 * np.pi * f * t) for f in freqs_hz) / len(freqs_hz)] * 2).astype(np.float32), sr
206
+
207
+
208
+ def test_chroma_of_a_c_major_triad_peaks_on_c_e_g():
209
+ # C4, E4, G4
210
+ audio, sr = _tones([261.63, 329.63, 392.00])
211
+ result = analysis.chroma(audio, sr)
212
+ c = result["chroma"]
213
+ assert len(c) == 12
214
+ strongest = sorted(range(12), key=lambda i: -c[i])[:3]
215
+ assert sorted(strongest) == [0, 4, 7]
216
+ assert max(c) == 1.0
217
+ assert all(c[i] < 0.5 for i in range(12) if i not in (0, 4, 7))
218
+
219
+
220
+ def test_chroma_puts_no_weight_on_the_wrong_class_for_a_low_bass_note():
221
+ # A1 and B1 with their first harmonics: down there a bin is wider than a semitone, so the
222
+ # fundamental must not add weight to the neighbouring classes (B flat, C)
223
+ c = analysis.chroma(*_tones([55.0, 110.0, 165.0]))["chroma"]
224
+ assert c[10] < 0.3
225
+ c = analysis.chroma(*_tones([61.74, 123.47, 185.2]))["chroma"]
226
+ assert c[10] < 0.5 and c[0] < 0.5
227
+ # B minor (B3, D4, F#4) over a B1 bass with its octave: the triad's classes stay on top
228
+ c = analysis.chroma(*_tones([61.74, 123.47, 246.94, 293.66, 369.99]))["chroma"]
229
+ assert sorted(sorted(range(12), key=lambda i: -c[i])[:3]) == [2, 6, 11]
230
+
231
+
232
+ def test_chroma_skips_silence_and_returns_zeros_for_it():
233
+ audio = np.zeros((2, 44100 * 2), dtype=np.float32)
234
+ result = analysis.chroma(audio, 44100)
235
+ assert result["chroma"] == [0.0] * 12
236
+ assert result["frames"] == 0
237
+
238
+
239
+ def test_chroma_per_segment_follows_a_chord_change_and_zeros_a_silent_segment():
240
+ # Two seconds of C major (C E G), two of A minor (A C E), then a second of silence
241
+ c_major, sr = _tones([261.63, 329.63, 392.00], seconds=2.0)
242
+ a_minor, _ = _tones([220.0, 261.63, 329.63], seconds=2.0)
243
+ audio = np.concatenate([c_major, a_minor, np.zeros((2, sr), dtype=np.float32)], axis=1)
244
+ result = analysis.chroma(audio, sr, segments=[[0.2, 1.8], [2.2, 3.8], [4.2, 4.9]])
245
+ first, second, silent = result["segments"]
246
+ assert sorted(sorted(range(12), key=lambda i: -first[i])[:3]) == [0, 4, 7]
247
+ assert sorted(sorted(range(12), key=lambda i: -second[i])[:3]) == [0, 4, 9]
248
+ assert silent == [0.0] * 12
249
+ assert len(result["chroma"]) == 12
@@ -0,0 +1,117 @@
1
+ """
2
+ Tests for sidecar/chords.py and the server's chords methods, with a fake lv_chordia
3
+ (the real model is not installed in CI).
4
+
5
+ Run with the sidecar venv:
6
+ ~/.reaper-mcp/sidecar-venv/bin/python -m pytest apps/reaper-mcp-server/sidecar/tests/test_chords.py -q
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import types
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import pytest
18
+
19
+ sys.path.insert(0, str(Path(__file__).parent.parent))
20
+
21
+ import chords # noqa: E402
22
+ import server # noqa: E402
23
+
24
+ SR = 22050
25
+
26
+
27
+ @pytest.fixture
28
+ def wav(tmp_path):
29
+ import soundfile as sf
30
+
31
+ path = tmp_path / "song.wav"
32
+ t = np.arange(SR * 8) / SR
33
+ sf.write(path, (0.2 * np.sin(2 * np.pi * 220 * t)).astype(np.float32), SR)
34
+ return str(path)
35
+
36
+
37
+ @pytest.fixture
38
+ def fake_model(monkeypatch):
39
+ """A fake lv_chordia that labels every 2 s of whatever file it gets, and records the call."""
40
+ calls = []
41
+
42
+ def chord_recognition(audio_path, chord_dict_name="submission"):
43
+ import soundfile as sf
44
+
45
+ seconds = sf.info(audio_path).duration
46
+ calls.append({"path": audio_path, "vocab": chord_dict_name, "seconds": round(seconds, 2)})
47
+ labels = ["A:min", "F:maj", "C:maj/3", "N"]
48
+ out, t, i = [], 0.0, 0
49
+ while t < seconds - 1e-6:
50
+ out.append({"start_time": t, "end_time": min(t + 2, seconds), "chord": labels[i % 4]})
51
+ t, i = t + 2, i + 1
52
+ return out
53
+
54
+ package = types.ModuleType("lv_chordia")
55
+ module = types.ModuleType("lv_chordia.chord_recognition")
56
+ module.chord_recognition = chord_recognition
57
+ package.chord_recognition = module
58
+ monkeypatch.setitem(sys.modules, "lv_chordia", package)
59
+ monkeypatch.setitem(sys.modules, "lv_chordia.chord_recognition", module)
60
+ monkeypatch.setattr(chords, "is_installed", lambda: True)
61
+ return calls
62
+
63
+
64
+ def call(method, **params):
65
+ return json.loads(server.dispatch(json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})))
66
+
67
+
68
+ def test_returns_the_models_segments_in_file_seconds(wav, fake_model):
69
+ result = call("chords", path=wav)["result"]
70
+ assert [s["label"] for s in result["segments"]] == ["A:min", "F:maj", "C:maj/3", "N"]
71
+ assert result["segments"][1] == {"start": 2.0, "end": 4.0, "label": "F:maj"}
72
+ assert result["durationSeconds"] == pytest.approx(8.0)
73
+ assert fake_model[0]["vocab"] == "submission"
74
+
75
+
76
+ def test_a_range_is_cut_out_and_its_times_stay_in_file_seconds(wav, fake_model):
77
+ result = call("chords", path=wav, startTime=3, endTime=7, vocabulary="ismir2017")["result"]
78
+ assert fake_model[0]["seconds"] == pytest.approx(4.0)
79
+ assert fake_model[0]["path"] != wav
80
+ assert not Path(fake_model[0]["path"]).exists()
81
+ assert result["segments"][0]["start"] == pytest.approx(3.0)
82
+ assert result["segments"][-1]["end"] == pytest.approx(7.0)
83
+ assert result["vocabulary"] == "ismir2017"
84
+
85
+
86
+ def test_refuses_an_unknown_vocabulary_and_a_missing_file(wav, fake_model):
87
+ assert call("chords", path=wav, vocabulary="jazz")["error"]["code"] == -32602
88
+ assert "not found" in call("chords", path="/nope/missing.wav")["error"]["message"]
89
+ assert fake_model == []
90
+
91
+
92
+ def test_without_the_model_says_how_to_install_it(wav, monkeypatch):
93
+ monkeypatch.setattr(chords, "is_installed", lambda: False)
94
+ error = call("chords", path=wav)["error"]
95
+ assert "--with-chords" in error["message"]
96
+ status = call("chords_status")["result"]
97
+ assert status["installed"] is False and "--with-chords" in status["message"]
98
+
99
+
100
+ def test_a_model_that_fails_to_load_is_not_installed_and_says_why(wav, monkeypatch):
101
+ # lv-chordia present but one of its imports missing (pydub's audioop on Python 3.13)
102
+ package = types.ModuleType("lv_chordia")
103
+ monkeypatch.setitem(sys.modules, "lv_chordia", package)
104
+ monkeypatch.setitem(sys.modules, "lv_chordia.chord_recognition", None)
105
+ status = call("chords_status")["result"]
106
+ assert status["installed"] is False
107
+ assert "does not load" in status["message"] and "--with-chords" in status["message"]
108
+ assert "does not load" in call("chords", path=wav)["error"]["message"]
109
+
110
+
111
+ def test_a_model_that_loads_is_installed(monkeypatch):
112
+ package = types.ModuleType("lv_chordia")
113
+ module = types.ModuleType("lv_chordia.chord_recognition")
114
+ package.chord_recognition = module
115
+ monkeypatch.setitem(sys.modules, "lv_chordia", package)
116
+ monkeypatch.setitem(sys.modules, "lv_chordia.chord_recognition", module)
117
+ assert call("chords_status")["result"]["installed"] is True
@@ -0,0 +1,163 @@
1
+ """
2
+ Tests for sidecar/mixcheck.py: loudness curve, per-band stereo correlation, masking.
3
+
4
+ Run with the sidecar venv (numpy, torch, soundfile):
5
+ ~/.reaper-mcp/sidecar-venv/bin/python -m pytest apps/reaper-mcp-server/sidecar/tests -q
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pytest
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent.parent))
17
+
18
+ import mixcheck # noqa: E402
19
+
20
+ SR = 48000
21
+
22
+
23
+ def tone(freq: float, dbfs: float, seconds: float, sr: int = SR) -> np.ndarray:
24
+ t = np.arange(int(seconds * sr)) / sr
25
+ return (10 ** (dbfs / 20) * np.sin(2 * np.pi * freq * t)).astype(np.float32)
26
+
27
+
28
+ def stereo(left: np.ndarray, right: np.ndarray) -> np.ndarray:
29
+ return np.stack([left, right])
30
+
31
+
32
+ def noise(seconds: float, seed: int, sr: int = SR) -> np.ndarray:
33
+ return (np.random.default_rng(seed).standard_normal(int(seconds * sr)) * 0.1).astype(np.float32)
34
+
35
+
36
+ # --- loudness curve ---------------------------------------------------------
37
+
38
+ def test_curve_follows_a_quiet_then_loud_song_and_finds_the_loud_part():
39
+ quiet, loud = tone(1000, -33, 10), tone(1000, -13, 10)
40
+ audio = stereo(np.concatenate([quiet, loud]), np.concatenate([quiet, loud]))
41
+ r = mixcheck.loudness_curve(audio, SR)
42
+ st = r["shortTerm"]["lufs"]
43
+ assert abs(st[0] - (-33)) < 0.5 and abs(st[-1] - (-13)) < 0.5
44
+ assert r["loudest"]["start"] >= 10 and abs(r["loudest"]["lufs"] - (-13)) < 0.5
45
+ assert r["quietest"]["start"] < 7
46
+
47
+
48
+ def test_curve_never_returns_more_points_than_its_cap_and_keeps_the_peak():
49
+ audio = stereo(tone(1000, -30, 300), tone(1000, -30, 300))
50
+ audio[:, 150 * SR:151 * SR] *= 10 # a 1 s burst 20 dB up
51
+ r = mixcheck.loudness_curve(audio, SR)
52
+ assert len(r["shortTerm"]["lufs"]) <= mixcheck.CURVE_POINTS
53
+ assert len(r["momentary"]["lufs"]) <= mixcheck.CURVE_POINTS
54
+ assert max(r["momentary"]["lufs"]) > -12, "a bucket keeps its loudest value"
55
+
56
+
57
+ def test_curve_reports_the_spans_over_a_target():
58
+ quiet, loud = tone(1000, -20, 10), tone(1000, -10, 6)
59
+ audio = stereo(np.concatenate([quiet, loud, quiet]), np.concatenate([quiet, loud, quiet]))
60
+ r = mixcheck.loudness_curve(audio, SR, target_lufs=-14)
61
+ assert len(r["overTarget"]) == 1
62
+ span = r["overTarget"][0]
63
+ assert 7 <= span["start"] <= 10 and 15 <= span["end"] <= 19
64
+ assert r["overTargetSeconds"] > 4
65
+
66
+
67
+ def test_curve_of_a_clip_shorter_than_3_s_says_only_momentary_is_reported():
68
+ r = mixcheck.loudness_curve(stereo(tone(1000, -20, 2), tone(1000, -20, 2)), SR)
69
+ assert "shortTerm" not in r and r["momentary"]["lufs"]
70
+ assert "momentary" in r["warning"]
71
+
72
+
73
+ # --- stereo correlation per band -------------------------------------------
74
+
75
+ def test_mono_source_correlates_fully_in_every_band():
76
+ n = noise(5, 1)
77
+ r = mixcheck.band_correlation(stereo(n, n), SR)
78
+ assert all(b["correlation"] > 0.99 for b in r["bands"])
79
+ assert r["flags"] == []
80
+
81
+
82
+ def test_out_of_phase_low_end_is_flagged_while_the_highs_stay_mono():
83
+ low = tone(60, -12, 5)
84
+ high = tone(4000, -12, 5)
85
+ r = mixcheck.band_correlation(stereo(low + high, -low + high), SR)
86
+ by = {b["band"]: b for b in r["bands"]}
87
+ assert by["low"]["correlation"] < -0.9
88
+ assert by["high"]["correlation"] > 0.9
89
+ assert any("low end" in f for f in r["flags"])
90
+
91
+
92
+ def test_wide_uncorrelated_mids_read_near_zero_with_side_close_to_mid():
93
+ r = mixcheck.band_correlation(stereo(noise(5, 1), noise(5, 2)), SR)
94
+ mid = next(b for b in r["bands"] if b["band"] == "mid")
95
+ assert abs(mid["correlation"]) < 0.1
96
+ assert abs(mid["sideToMidDb"]) < 1.5
97
+ assert len(mid["series"]) <= mixcheck.BAND_SERIES_POINTS
98
+
99
+
100
+ def test_band_correlation_refuses_a_mono_file():
101
+ with pytest.raises(ValueError, match="stereo"):
102
+ mixcheck.band_correlation(tone(100, -12, 2)[None, :], SR)
103
+
104
+
105
+ # --- masking -----------------------------------------------------------------
106
+
107
+ def test_two_tracks_in_the_same_band_at_the_same_level_conflict_there():
108
+ bass = stereo(tone(100, -12, 5), tone(100, -12, 5))
109
+ kick = stereo(tone(100, -13, 5) + tone(3000, -30, 5), tone(100, -13, 5) + tone(3000, -30, 5))
110
+ r = mixcheck.masking([("Bass", bass), ("Kick", kick)], SR)
111
+ top = r["conflicts"][0]
112
+ assert top["hz"] == 100 and top["score"] > 0.8
113
+ assert {top["a"], top["b"]} == {"Bass", "Kick"}
114
+ assert "cut" in top["suggestion"]
115
+ assert len(r["pairs"]) == 1 and len(r["pairs"][0]["scores"]) == len(r["bandsHz"])
116
+
117
+
118
+ def test_tracks_in_different_bands_do_not_conflict():
119
+ low = stereo(tone(80, -12, 5), tone(80, -12, 5))
120
+ high = stereo(tone(5000, -12, 5), tone(5000, -12, 5))
121
+ r = mixcheck.masking([("Bass", low), ("Hat", high)], SR)
122
+ assert r["conflicts"] == []
123
+
124
+
125
+ def test_masking_suggests_cutting_the_track_the_band_matters_less_to():
126
+ # The guitar's energy is mostly at 1 kHz; at 250 Hz it is a minor part of it,
127
+ # while the 250 Hz tone is the whole of the pad
128
+ pad = tone(250, -18, 5)
129
+ guitar = tone(1000, -6, 5) + tone(250, -18, 5)
130
+ r = mixcheck.masking([("Pad", stereo(pad, pad)), ("Guitar", stereo(guitar, guitar))], SR)
131
+ at250 = next(c for c in r["conflicts"] if c["hz"] == 250)
132
+ assert at250["suggestion"].startswith("cut Guitar")
133
+
134
+
135
+ def test_masking_needs_two_tracks():
136
+ with pytest.raises(ValueError):
137
+ mixcheck.masking([("Solo", stereo(noise(1, 1), noise(1, 1)))], SR)
138
+
139
+
140
+ # --- speed ------------------------------------------------------------------
141
+ # Bounds sit ~10× over what these take on a laptop (900 s curve ≈ 1.6 s, stereo ≈ 1.4 s, masking ≈ 0.15 s),
142
+ # so only a real regression (a per-sample loop, a quadratic step) trips them.
143
+
144
+
145
+ def test_the_longest_inputs_each_check_accepts_finish_in_seconds():
146
+ import time
147
+
148
+ song = stereo(noise(900, 1), noise(900, 2))
149
+ t0 = time.perf_counter()
150
+ curve = mixcheck.loudness_curve(song, SR, -14)
151
+ curve_s = time.perf_counter() - t0
152
+ t0 = time.perf_counter()
153
+ bands = mixcheck.band_correlation(song, SR)
154
+ bands_s = time.perf_counter() - t0
155
+ tracks = [(f"T{i}", stereo(noise(60, 10 + i), noise(60, 20 + i))) for i in range(8)]
156
+ t0 = time.perf_counter()
157
+ masking = mixcheck.masking(tracks, SR)
158
+ masking_s = time.perf_counter() - t0
159
+
160
+ assert len(curve["shortTerm"]["lufs"]) <= 240
161
+ assert len(bands["bands"]) == 5
162
+ assert len(masking["pairs"]) == 28
163
+ assert curve_s < 20 and bands_s < 20 and masking_s < 5, (curve_s, bands_s, masking_s)
@@ -0,0 +1,73 @@
1
+ """
2
+ Tests for sidecar/separation.py. The Demucs run is skipped unless demucs is installed
3
+ (`reaper-mcp setup-sidecar --with-separation`); it downloads the model on first use.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent))
15
+
16
+ import separation # noqa: E402
17
+
18
+
19
+ def _mix(path: Path, sr: int = 44100, seconds: float = 8.0) -> None:
20
+ import soundfile as sf
21
+
22
+ t = np.arange(int(seconds * sr)) / sr
23
+ bass = 0.3 * np.sin(2 * np.pi * 55 * t)
24
+ drums = np.zeros_like(t)
25
+ for k in np.arange(0, seconds, 0.5):
26
+ i, n = int(k * sr), int(0.08 * sr)
27
+ drums[i:i + n] += np.random.default_rng(int(k * 10)).standard_normal(min(n, t.size - i)) \
28
+ * np.exp(-np.arange(min(n, t.size - i)) / (0.015 * sr)) * 0.5
29
+ x = (bass + drums).astype(np.float32)
30
+ sf.write(str(path), np.stack([x, x], axis=1), sr)
31
+
32
+
33
+ def test_refuses_an_unknown_model_or_stem_without_writing():
34
+ with pytest.raises(ValueError, match="Unknown model"):
35
+ separation.separate("/nope.wav", "/tmp/out-never", model_name="spleeter")
36
+ with pytest.raises(ValueError, match="has no stem guitar"):
37
+ separation.separate("/nope.wav", "/tmp/out-never", stems=["guitar"])
38
+
39
+
40
+ @pytest.mark.skipif(not separation.is_installed(), reason="demucs not installed (setup-sidecar --with-separation)")
41
+ def test_writes_only_the_asked_stems_as_float_wavs_at_the_model_rate(tmp_path):
42
+ import soundfile as sf
43
+
44
+ _mix(tmp_path / "mix.wav")
45
+ r = separation.separate(str(tmp_path / "mix.wav"), str(tmp_path / "stems"), stems=["drums", "bass"])
46
+ assert [s["stem"] for s in r["stems"]] == ["drums", "bass"]
47
+ assert sorted(p.name for p in (tmp_path / "stems").iterdir()) == ["bass.wav", "drums.wav"]
48
+ info = sf.info(str(tmp_path / "stems" / "bass.wav"))
49
+ assert info.subtype == "FLOAT" and info.samplerate == 44100 and info.channels == 2
50
+ assert abs(info.duration - 8.0) < 0.01
51
+ bass = next(s for s in r["stems"] if s["stem"] == "bass")
52
+ assert bass["lufs"] is not None and bass["lufs"] > -40, "the bass line lands in the bass stem"
53
+
54
+
55
+ @pytest.mark.skipif(not separation.is_installed(), reason="demucs not installed (setup-sidecar --with-separation)")
56
+ def test_an_accelerator_failure_reruns_on_cpu_and_says_so(tmp_path, monkeypatch):
57
+ import demucs.apply
58
+
59
+ real = demucs.apply.apply_model
60
+
61
+ def accelerator_lacks_an_op(*args, **kw):
62
+ # Demucs calls apply_model recursively with positional arguments: only the device keyword matters.
63
+ if kw.get("device") == "mps":
64
+ raise RuntimeError("op not implemented for 'mps'")
65
+ return real(*args, **kw)
66
+
67
+ monkeypatch.setattr(separation, "_device", lambda: "mps")
68
+ monkeypatch.setattr(demucs.apply, "apply_model", accelerator_lacks_an_op)
69
+ _mix(tmp_path / "mix.wav", seconds=3.0)
70
+ r = separation.separate(str(tmp_path / "mix.wav"), str(tmp_path / "stems"), stems=["bass"])
71
+ assert r["device"] == "cpu_fallback" and r["fallbackFrom"] == "mps"
72
+ assert "not implemented" in r["fallbackError"]
73
+ assert [s["stem"] for s in r["stems"]] == ["bass"]