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