@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,243 @@
1
+ """
2
+ Tests for sidecar/verify.py (verify_edit): what the agent is told about an edit.
3
+
4
+ Run with the sidecar venv:
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 verify # noqa: E402
19
+
20
+ SR = 44100
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def source() -> np.ndarray:
25
+ """3 s of voiced-like material: harmonics under a moving envelope plus a little breath noise."""
26
+ rng = np.random.default_rng(7)
27
+ t = np.arange(3 * SR) / SR
28
+ f0 = 140 + 25 * np.sin(2 * np.pi * 0.7 * t)
29
+ phase = 2 * np.pi * np.cumsum(f0) / SR
30
+ voiced = sum(np.sin(h * phase) / h for h in range(1, 12))
31
+ envelope = 0.5 + 0.5 * np.sin(2 * np.pi * 1.3 * t) ** 2
32
+ breath = np.convolve(rng.standard_normal(len(t)), np.ones(8) / 8, mode="same") * 0.05
33
+ return (0.2 * (envelope * voiced + breath)).astype(np.float64)
34
+
35
+
36
+ def shifted(x: np.ndarray, ms: float) -> np.ndarray:
37
+ n = int(round(SR * ms / 1000))
38
+ return np.concatenate([np.zeros(n), x])[: len(x)]
39
+
40
+
41
+ def test_an_untouched_copy_passes_with_nothing_to_report(source):
42
+ result = verify.verify_edit(source, source.copy(), SR)
43
+ assert result["verdict"] == "ok"
44
+ assert result["checks"]["null"]["residualDb"] <= -100
45
+ assert result["checks"]["null"]["delayMs"] == 0
46
+ assert result["checks"]["null"]["gainDb"] == 0
47
+
48
+
49
+ def test_a_gain_change_is_reported_as_a_number_not_as_damage(source):
50
+ result = verify.verify_edit(source, source * 10 ** (-3 / 20), SR)
51
+ null = result["checks"]["null"]
52
+ assert result["verdict"] == "ok"
53
+ assert null["gainDb"] == pytest.approx(-3.0, abs=0.05)
54
+ assert null["residualDb"] <= -60
55
+
56
+
57
+ def test_a_pure_shift_is_reported_as_its_delay_and_passes(source):
58
+ result = verify.verify_edit(source, shifted(source, 4.0), SR)
59
+ null = result["checks"]["null"]
60
+ assert result["verdict"] == "ok"
61
+ assert null["delayMs"] == pytest.approx(4.0, abs=0.05)
62
+ assert null["residualDb"] <= -50
63
+
64
+
65
+ def test_the_reference_level_is_reported_so_a_noise_floor_residual_can_be_read_in_context(source):
66
+ quiet = source * 10 ** (-40 / 20)
67
+ rounded = np.round(quiet * 32768) / 32768 # a 16-bit bounce of the quiet take
68
+ null = verify.verify_edit(quiet, rounded, SR)["checks"]["null"]
69
+ loud = verify.verify_edit(source, np.round(source * 32768) / 32768, SR)["checks"]["null"]
70
+ assert null["referenceRmsDb"] < loud["referenceRmsDb"] - 30
71
+ assert null["residualDb"] > loud["residualDb"] + 30 # the same rounding is a far bigger share of a quiet take
72
+
73
+
74
+ def test_a_fractional_sample_shift_is_found(source):
75
+ n = 37.4 # samples
76
+ edited = np.fft.irfft(np.fft.rfft(source) * np.exp(-2j * np.pi * np.fft.rfftfreq(len(source)) * n), len(source))
77
+ null = verify.verify_edit(source, edited, SR)["checks"]["null"]
78
+ assert null["delayMs"] == pytest.approx(1000 * n / SR, abs=0.02)
79
+ assert null["residualDb"] <= -40
80
+
81
+
82
+ def test_a_polarity_flip_is_reported_and_passes(source):
83
+ result = verify.verify_edit(source, -source, SR)
84
+ assert result["verdict"] == "ok"
85
+ assert result["checks"]["null"]["polarityInverted"] is True
86
+
87
+
88
+ def test_added_noise_fails_an_edit_that_should_be_transparent(source):
89
+ noise = np.random.default_rng(1).standard_normal(len(source))
90
+ noisy = source + noise * np.sqrt(np.mean(source**2) / 10 ** (25 / 10) / np.mean(noise**2))
91
+ result = verify.verify_edit(source, noisy, SR)
92
+ assert result["verdict"] == "fail"
93
+ assert result["checks"]["null"]["residualDb"] == pytest.approx(-25, abs=1.5)
94
+
95
+
96
+ def test_the_same_difference_is_only_reported_when_the_edit_is_meant_to_change_the_sound(source):
97
+ noise = np.random.default_rng(1).standard_normal(len(source)) * 0.01
98
+ result = verify.verify_edit(source, source + noise, SR, expect="changed")
99
+ assert result["checks"]["null"]["verdict"] == "info"
100
+ assert result["verdict"] == "ok"
101
+ assert result["checks"]["null"]["residualDb"] > verify.NULL_OK_DB
102
+
103
+
104
+ def test_a_hard_splice_is_flagged_where_it_happens(source):
105
+ spliced = source.copy()
106
+ cut = len(source) // 2
107
+ spliced[cut:] *= 0.5
108
+ spliced[cut : cut + 8] += 0.2
109
+ result = verify.verify_edit(source, spliced, SR, expect="changed")
110
+ disc = result["checks"]["discontinuity"]
111
+ assert result["verdict"] == "fail"
112
+ assert disc["verdict"] == "fail"
113
+ assert disc["atSeconds"] == pytest.approx(cut / SR, abs=0.002)
114
+
115
+
116
+ def test_material_that_is_naturally_spiky_is_not_blamed_for_its_own_spikes():
117
+ rng = np.random.default_rng(3)
118
+ spiky = rng.standard_normal(3 * SR) * 0.01
119
+ spiky[::SR // 4] += 0.5 # four hits a second, in the reference and in the edit
120
+ result = verify.verify_edit(spiky, spiky * 0.9, SR)
121
+ assert result["checks"]["discontinuity"]["verdict"] == "ok"
122
+
123
+
124
+ def test_a_delayed_copy_of_material_that_starts_abruptly_is_not_called_a_click():
125
+ t = np.arange(3 * SR) / SR
126
+ abrupt = 0.3 * np.sin(2 * np.pi * 200 * t + 1.2) # starts mid-wave, so a delayed copy starts with a step
127
+ result = verify.verify_edit(abrupt, shifted(abrupt, 5.0), SR)
128
+ assert result["checks"]["discontinuity"]["verdict"] == "ok"
129
+ assert result["checks"]["null"]["delayMs"] == pytest.approx(5.0, abs=0.05)
130
+
131
+
132
+ def _stereo_tone(seed: int = 2) -> np.ndarray:
133
+ rng = np.random.default_rng(seed)
134
+ t = np.arange(3 * SR) / SR
135
+ tone = 0.2 * np.sin(2 * np.pi * 220 * t) + 0.01 * rng.standard_normal(len(t))
136
+ return np.stack([tone, tone])
137
+
138
+
139
+ def test_a_click_on_one_channel_is_found_and_the_channel_is_named():
140
+ reference = _stereo_tone()
141
+ clicked = reference.copy()
142
+ clicked[1, len(reference[0]) // 2] += 0.5
143
+ disc = verify.verify_edit(reference, clicked, SR, expect="changed")["checks"]["discontinuity"]
144
+ assert disc["verdict"] == "fail"
145
+ assert disc["channel"] == 1
146
+ assert disc["atSeconds"] == pytest.approx(1.5, abs=0.002)
147
+
148
+
149
+ def test_opposite_clicks_on_left_and_right_do_not_cancel_out():
150
+ reference = _stereo_tone()
151
+ clicked = reference.copy()
152
+ k = len(reference[0]) // 2
153
+ clicked[0, k] += 0.5
154
+ clicked[1, k] -= 0.5
155
+ assert verify.verify_edit(reference, clicked, SR, expect="changed")["checks"]["discontinuity"]["verdict"] == "fail"
156
+
157
+
158
+ def test_a_spiky_unchanged_channel_does_not_hide_a_click_on_the_other():
159
+ t = np.arange(3 * SR) / SR
160
+ left = 0.2 * np.sin(2 * np.pi * 220 * t)
161
+ left[::9000] += 0.5 # naturally spiky, identical in the edit
162
+ right = 0.2 * np.sin(2 * np.pi * 330 * t) + 0.002 * np.random.default_rng(4).standard_normal(len(t))
163
+ clicked = right.copy()
164
+ clicked[SR:] += 0.3 # a step
165
+ disc = verify.verify_edit(np.stack([left, right]), np.stack([left, clicked]), SR, expect="changed")["checks"]["discontinuity"]
166
+ assert disc["verdict"] == "fail"
167
+ assert disc["channel"] == 1
168
+
169
+
170
+ def test_a_clean_stereo_edit_names_no_channel_problem():
171
+ reference = _stereo_tone()
172
+ result = verify.verify_edit(reference, reference * 0.7, SR)
173
+ assert result["verdict"] == "ok"
174
+ assert result["checks"]["discontinuity"]["verdict"] == "ok"
175
+
176
+
177
+ def test_a_stretch_skips_the_null_and_still_checks_for_clicks(source):
178
+ stretched = source[: int(len(source) / 1.1)].copy()
179
+ clean = verify.verify_edit(source, stretched, SR, time_scale=1.1)
180
+ assert clean["checks"]["null"]["verdict"] == "skipped"
181
+ assert clean["verdict"] == "ok"
182
+
183
+ stretched[len(stretched) // 2 :] *= 0.4
184
+ stretched[len(stretched) // 2 : len(stretched) // 2 + 6] += 0.25
185
+ assert verify.verify_edit(source, stretched, SR, time_scale=1.1)["verdict"] == "fail"
186
+
187
+
188
+ def test_a_stereo_gain_change_passes_and_is_reported_in_dB(source):
189
+ stereo = np.stack([source, source * 0.8])
190
+ result = verify.verify_edit(stereo, stereo * 0.5, SR)
191
+ assert result["checks"]["null"]["gainDb"] == pytest.approx(-6.0, abs=0.05)
192
+ assert result["verdict"] == "ok"
193
+
194
+
195
+ def test_swapping_left_and_right_is_not_hidden_by_the_mono_sum():
196
+ rng = np.random.default_rng(5)
197
+ left = np.convolve(rng.standard_normal(2 * SR), np.ones(16) / 16, mode="same") * 0.2
198
+ right = np.convolve(rng.standard_normal(2 * SR), np.ones(16) / 16, mode="same") * 0.2
199
+ result = verify.verify_edit(np.stack([left, right]), np.stack([right, left]), SR)
200
+ assert result["verdict"] == "fail"
201
+ assert result["checks"]["null"]["residualDb"] > verify.NULL_WARN_DB
202
+
203
+
204
+ def test_a_mono_edit_of_a_stereo_reference_is_compared_on_the_sum(source):
205
+ result = verify.verify_edit(np.stack([source, source]), source, SR)
206
+ assert result["verdict"] == "ok"
207
+
208
+
209
+ @pytest.mark.parametrize("which", ["reference", "edited"])
210
+ def test_silence_is_refused_with_a_reason(source, which):
211
+ silent = np.zeros_like(source)
212
+ args = (silent, source) if which == "reference" else (source, silent)
213
+ assert "silent" in verify.verify_edit(*args, SR)["error"]
214
+
215
+
216
+ def test_too_short_audio_is_refused(source):
217
+ assert "too short" in verify.verify_edit(source[:1000], source[:1000], SR)["error"]
218
+
219
+
220
+ def test_an_unknown_expectation_is_refused(source):
221
+ assert "expect" in verify.verify_edit(source, source, SR, expect="better")["error"]
222
+
223
+
224
+ def test_a_silent_edit_is_refused_even_when_the_timing_changed(source):
225
+ assert "silent" in verify.verify_edit(source, np.zeros(int(len(source) / 1.1)), SR, time_scale=1.1)["error"]
226
+
227
+
228
+ def test_a_reference_covering_another_range_is_refused_not_failed(source):
229
+ longer = np.concatenate([source[: 5 * SR // 2], source])
230
+ assert "same range" in verify.verify_edit(longer, source, SR)["error"]
231
+
232
+
233
+ def test_a_polarity_flip_of_one_channel_fails_instead_of_being_called_silent():
234
+ reference = _stereo_tone()
235
+ flipped = reference.copy()
236
+ flipped[1] *= -1 # the mono sum of the edit cancels to nothing
237
+ result = verify.verify_edit(reference, flipped, SR)
238
+ assert "error" not in result
239
+ assert result["verdict"] == "fail"
240
+
241
+
242
+ def test_empty_audio_is_refused_as_too_short():
243
+ assert "too short" in verify.verify_edit(np.zeros(0), np.zeros(0), SR)["error"]
@@ -0,0 +1,268 @@
1
+ """Public validation and opt-in native SoundFont rendering checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes.util
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import pytest
12
+ import soundfile as sf
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+
16
+
17
+ def request(tmp_path, **overrides):
18
+ font = tmp_path / "fixture.sf2"
19
+ if not font.exists():
20
+ font.write_bytes(b"validation only, not a SoundFont")
21
+ return {
22
+ "soundfontPath": str(font),
23
+ "outDir": str(tmp_path / "render"),
24
+ "durationSeconds": 2,
25
+ "tracks": [{
26
+ "name": "melody", "channel": 0, "program": 0, "gain": 0.8,
27
+ "notes": [{"time": 0, "duration": 0.5, "pitch": 60, "velocity": 100}],
28
+ }],
29
+ **overrides,
30
+ }
31
+
32
+
33
+ @pytest.mark.parametrize("params", [None, [], "render", 42, {}])
34
+ def test_refuses_a_malformed_request_without_creating_output(tmp_path, params):
35
+ from voice_render import render
36
+
37
+ with pytest.raises(ValueError):
38
+ render(params)
39
+ assert list(tmp_path.iterdir()) == []
40
+
41
+
42
+ @pytest.mark.parametrize("field,value", [
43
+ ("durationSeconds", 0), ("durationSeconds", 241),
44
+ ("durationSeconds", float("nan")), ("durationSeconds", float("inf")),
45
+ ("durationSeconds", True), ("durationSeconds", "2"),
46
+ ("tracks", []), ("tracks", {}), ("tracks", [None]),
47
+ ("name", "../escape"), ("name", "/absolute"), ("name", "mix"),
48
+ ("name", "Melody"), ("name", "bad\\path"), ("name", ""),
49
+ ("channel", -1), ("channel", 16), ("channel", True), ("channel", 0.5),
50
+ ("program", -1), ("program", 128), ("program", "0"),
51
+ ("gain", -0.1), ("gain", 1.1), ("gain", float("nan")),
52
+ ("notes", None), ("notes", [None]),
53
+ ("time", -1), ("time", float("inf")), ("time", 2),
54
+ ("duration", 0), ("duration", -1), ("duration", 2.1),
55
+ ("duration", float("nan")),
56
+ ("pitch", -1), ("pitch", 128), ("pitch", 60.5),
57
+ ("velocity", 0), ("velocity", 128), ("velocity", True),
58
+ ])
59
+ def test_refuses_invalid_score_fields_before_writing(tmp_path, field, value):
60
+ from voice_render import render
61
+
62
+ params = request(tmp_path)
63
+ target = params
64
+ if field in {"name", "channel", "program", "gain", "notes"}:
65
+ target = params["tracks"][0]
66
+ if field in {"time", "duration", "pitch", "velocity"}:
67
+ target = params["tracks"][0]["notes"][0]
68
+ target[field] = value
69
+ with pytest.raises(ValueError, match=field):
70
+ render(params)
71
+ assert not (tmp_path / "render").exists()
72
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["fixture.sf2"]
73
+
74
+
75
+ @pytest.mark.parametrize("case", ["font-relative", "font-missing", "font-directory", "font-extension",
76
+ "out-relative", "out-parent", "out-traversal", "out-existing", "out-symlink",
77
+ "font-nul", "out-nul"])
78
+ def test_refuses_unsafe_paths_and_preserves_existing_files(tmp_path, case):
79
+ from voice_render import render
80
+
81
+ params = request(tmp_path)
82
+ sentinel = tmp_path / "keep.txt"
83
+ sentinel.write_text("user data")
84
+ if case == "font-relative":
85
+ params["soundfontPath"] = "fixture.sf2"
86
+ elif case == "font-missing":
87
+ params["soundfontPath"] = str(tmp_path / "missing.sf2")
88
+ elif case == "font-directory":
89
+ (tmp_path / "directory.sf2").mkdir()
90
+ params["soundfontPath"] = str(tmp_path / "directory.sf2")
91
+ elif case == "font-extension":
92
+ params["soundfontPath"] = str(sentinel)
93
+ elif case == "out-relative":
94
+ params["outDir"] = "render"
95
+ elif case == "out-parent":
96
+ params["outDir"] = str(tmp_path / "missing" / "render")
97
+ elif case == "out-traversal":
98
+ params["outDir"] = str(tmp_path / ".." / "render")
99
+ elif case == "out-existing":
100
+ params["outDir"] = str(tmp_path)
101
+ elif case == "out-symlink":
102
+ (tmp_path / "render").symlink_to(tmp_path / "missing-directory")
103
+ elif case == "font-nul":
104
+ params["soundfontPath"] += "\0.sf2"
105
+ else:
106
+ params["outDir"] += "\0"
107
+ before = sorted(p.name for p in tmp_path.iterdir())
108
+ with pytest.raises(ValueError, match="soundfontPath|outDir"):
109
+ render(params)
110
+ assert sorted(p.name for p in tmp_path.iterdir()) == before
111
+ assert sentinel.read_text() == "user data"
112
+
113
+
114
+ @pytest.fixture
115
+ def soundfont():
116
+ """Opt in with REAPER_MCP_TEST_SOUNDFONT=/absolute/path/to/a/GM.sf2."""
117
+ path = os.environ.get("REAPER_MCP_TEST_SOUNDFONT")
118
+ if not path or not ctypes.util.find_library("fluidsynth"):
119
+ pytest.skip("set REAPER_MCP_TEST_SOUNDFONT and install libfluidsynth for native checks")
120
+ return path
121
+
122
+
123
+ def test_renders_stereo_float_stems_that_sum_to_the_mix(tmp_path, soundfont):
124
+ from voice_render import render
125
+
126
+ params = request(tmp_path, soundfontPath=soundfont)
127
+ params["tracks"].append({
128
+ "name": "drums", "channel": 9, "program": 0, "gain": 0.4,
129
+ "notes": [{"time": 0.6, "duration": 0.1, "pitch": 36, "velocity": 110}],
130
+ })
131
+ result = render(params)
132
+ assert result["engine"] == "FluidSynth"
133
+ assert result["soundfontPath"] == soundfont
134
+ assert result["sampleRate"] == 22050
135
+ assert result["durationSeconds"] == 2
136
+ assert 0 < result["gain"] <= 1
137
+ assert [s["name"] for s in result["stems"]] == ["melody", "drums"]
138
+ assert sorted(p.name for p in (tmp_path / "render").iterdir()) == ["drums.wav", "melody.wav", "mix.wav"]
139
+ stems = [sf.read(s["path"], dtype="float32")[0] for s in result["stems"]]
140
+ mix, sr = sf.read(result["mixPath"], dtype="float32")
141
+ assert sr == 22050 and mix.shape == (44100, 2)
142
+ assert all(sf.info(s["path"]).subtype == "FLOAT" for s in result["stems"])
143
+ assert sf.info(result["mixPath"]).subtype == "FLOAT"
144
+ assert all(np.max(np.abs(stem)) > 0.001 for stem in stems)
145
+ assert np.max(np.abs(stems[1][:int(0.6 * sr)])) == 0
146
+ assert np.max(np.abs(stems[0][int(0.51 * sr):int(0.6 * sr)])) > 0, "note releases are retained"
147
+ np.testing.assert_array_equal(mix, np.sum(stems, axis=0, dtype=np.float32))
148
+ peak = float(np.max(np.abs(mix)))
149
+ assert peak <= 0.9
150
+ assert result["peakDb"] == pytest.approx(20 * np.log10(peak), abs=1e-5)
151
+
152
+
153
+ def test_common_attenuation_preserves_stem_balance_at_the_peak_ceiling(tmp_path, soundfont):
154
+ from voice_render import render
155
+
156
+ notes = [{"time": 0, "duration": 0.7, "pitch": pitch, "velocity": 127} for pitch in range(36, 84)]
157
+ tracks = [{"name": f"part{i}", "channel": 0, "program": 19,
158
+ "gain": 0.25 if i == 1 else 1, "notes": notes} for i in range(8)]
159
+ result = render(request(tmp_path, soundfontPath=soundfont, tracks=tracks, durationSeconds=1))
160
+ stems = [sf.read(s["path"], dtype="float32")[0] for s in result["stems"]]
161
+ mix = sf.read(result["mixPath"], dtype="float32")[0]
162
+ assert 0 < result["gain"] < 1, "the loud eight-part chord needs attenuation"
163
+ assert 0.89 < np.max(np.abs(mix)) <= 0.9
164
+ np.testing.assert_allclose(stems[1], stems[0] * 0.25, atol=1e-7)
165
+ np.testing.assert_array_equal(mix, np.sum(stems, axis=0, dtype=np.float32))
166
+
167
+
168
+ def test_adjacent_and_overlapping_repeats_stay_audible_until_their_own_noteoff(tmp_path, soundfont):
169
+ from voice_render import render
170
+
171
+ params = request(tmp_path, soundfontPath=soundfont)
172
+ # A sustaining GM organ, not a patch whose natural decay could mimic noteoff.
173
+ params["tracks"][0].update(program=16, notes=[
174
+ {"time": 0.1, "duration": 0.4, "pitch": 60, "velocity": 100},
175
+ {"time": 0.5, "duration": 0.4, "pitch": 60, "velocity": 100},
176
+ {"time": 0.7, "duration": 0.5, "pitch": 60, "velocity": 100},
177
+ ])
178
+ result = render(params)
179
+ audio, sr = sf.read(result["mixPath"])
180
+ held = np.sqrt(np.mean(audio[int(0.75 * sr):int(0.85 * sr)] ** 2))
181
+ after_old_off = np.sqrt(np.mean(audio[int(1.0 * sr):int(1.1 * sr)] ** 2))
182
+ assert held > 0.001
183
+ assert np.sqrt(np.mean(audio[int(0.55 * sr):int(0.65 * sr)] ** 2)) > 0.001
184
+ assert after_old_off > held * 0.5, "the older repeat's 0.9 s noteoff must not silence the new note"
185
+ assert np.max(np.abs(audio[:int(0.1 * sr)])) == 0
186
+ assert np.sqrt(np.mean(audio[int(1.8 * sr):] ** 2)) < held * 0.1
187
+
188
+
189
+ @pytest.mark.parametrize("case", ["track-count", "total-notes", "duplicate-name", "invalid-last-track"])
190
+ def test_validates_the_whole_bounded_score_before_any_output(tmp_path, case):
191
+ from voice_render import render
192
+
193
+ params = request(tmp_path)
194
+ track = params["tracks"][0]
195
+ if case == "track-count":
196
+ params["tracks"] = [{**track, "name": f"part{i}"} for i in range(9)]
197
+ elif case == "total-notes":
198
+ params["tracks"] = [{**track, "name": f"part{i}", "notes": track["notes"] * 10001} for i in range(2)]
199
+ elif case == "duplicate-name":
200
+ params["tracks"].append(dict(track))
201
+ else:
202
+ params["tracks"].append({**track, "name": "bass", "gain": float("inf")})
203
+ with pytest.raises(ValueError):
204
+ render(params)
205
+ assert not (tmp_path / "render").exists()
206
+
207
+
208
+ @pytest.mark.skipif(not ctypes.util.find_library("fluidsynth"), reason="libfluidsynth is not installed")
209
+ def test_failed_soundfont_load_removes_only_the_new_output_folder(tmp_path):
210
+ from voice_render import render
211
+
212
+ params = request(tmp_path)
213
+ sentinel = tmp_path / "keep.txt"
214
+ sentinel.write_text("user data")
215
+ for _ in range(2):
216
+ with pytest.raises(RuntimeError, match="SoundFont|soundfontPath"):
217
+ render(params)
218
+ assert not (tmp_path / "render").exists()
219
+ assert sentinel.read_text() == "user data"
220
+ assert (tmp_path / "fixture.sf2").read_bytes() == b"validation only, not a SoundFont"
221
+
222
+
223
+ def test_silent_parts_remain_silent_with_json_safe_metadata(tmp_path, soundfont):
224
+ from voice_render import render
225
+
226
+ params = request(tmp_path, soundfontPath=soundfont, durationSeconds=1.00001)
227
+ params["tracks"][0]["gain"] = 0
228
+ params["tracks"].append({**params["tracks"][0], "name": "harmony", "gain": 1, "notes": []})
229
+ result = render(params)
230
+ assert result["gain"] == 1 and result["peakDb"] is None
231
+ assert result["durationSeconds"] == 22051 / 22050
232
+ for path in [result["mixPath"], *(s["path"] for s in result["stems"])]:
233
+ audio, sr = sf.read(path)
234
+ assert audio.shape == (22051, 2) and sr == 22050
235
+ assert np.count_nonzero(audio) == 0
236
+
237
+
238
+ def test_a_write_failure_removes_partial_stems_and_allows_a_clean_retry(tmp_path, soundfont, monkeypatch):
239
+ from voice_render import render
240
+
241
+ params = request(tmp_path, soundfontPath=soundfont)
242
+ params["tracks"].append({**params["tracks"][0], "name": "bass"})
243
+ open_wav = sf.SoundFile
244
+
245
+ def disk_full(path, mode="r", *args, **kwargs):
246
+ if Path(path).name == "bass.wav" and mode == "w":
247
+ assert (tmp_path / "render" / "melody.wav").is_file()
248
+ raise OSError("No space left on device")
249
+ return open_wav(path, mode, *args, **kwargs)
250
+
251
+ # Only the output-file boundary fails. FluidSynth is real throughout.
252
+ with monkeypatch.context() as patch:
253
+ patch.setattr(sf, "SoundFile", disk_full)
254
+ with pytest.raises(OSError, match="No space left"):
255
+ render(params)
256
+ assert not (tmp_path / "render").exists()
257
+ assert (tmp_path / "fixture.sf2").is_file()
258
+ result = render(params)
259
+ assert all(Path(s["path"]).is_file() for s in result["stems"])
260
+
261
+
262
+ @pytest.mark.skipif(bool(ctypes.util.find_library("fluidsynth")), reason="libfluidsynth is installed")
263
+ def test_missing_native_library_explains_installation_without_creating_output(tmp_path):
264
+ from voice_render import render
265
+
266
+ with pytest.raises(RuntimeError, match="brew install fluid-synth.*apt install libfluidsynth3"):
267
+ render(request(tmp_path))
268
+ assert not (tmp_path / "render").exists()
@@ -0,0 +1,140 @@
1
+ """Synthetic sources with known pitches/timing, independent of the detector."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pytest
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+ from voice_sketch import transcribe
11
+ from server import dispatch
12
+
13
+ SR = 22050
14
+
15
+
16
+ def tone(pitch, seconds):
17
+ t = np.arange(int(seconds * SR)) / SR
18
+ f = 440 * 2 ** ((pitch - 69) / 12)
19
+ # Strong second harmonic catches octave mistakes on a voice-like signal.
20
+ return (0.24 * np.sin(2 * np.pi * f * t) + 0.3 * np.sin(4 * np.pi * f * t)).astype(np.float32)
21
+
22
+
23
+ def test_hummed_phrase_keeps_pitch_order_repeated_notes_and_silences():
24
+ audio = np.zeros((1, SR * 5), dtype=np.float32)
25
+ for start, pitch in [(0.23, 57), (0.97, 60), (1.72, 64), (2.48, 64), (3.98, 59)]:
26
+ sound = tone(pitch, 0.4)
27
+ audio[0, int(start * SR):int(start * SR) + len(sound)] = sound
28
+ result = transcribe(audio, SR, mode="melody", bpm=120)
29
+ assert [n["pitch"] for n in result["melody"]] == [57, 60, 64, 64, 59]
30
+ assert result["drums"] == []
31
+ beats = [n["beat"] for n in result["melody"]]
32
+ assert beats == pytest.approx([0, 1.5, 3, 4.5, 7.5], abs=0.25)
33
+ assert all(a["beat"] + a["lengthBeats"] <= b["beat"] for a, b in zip(result["melody"], result["melody"][1:]))
34
+ coarse = transcribe(audio, SR, mode="melody", bpm=60, grid=1)
35
+ # Key evidence belongs to the voice, not to notes discarded by a coarse grid.
36
+ assert coarse["chroma"] == pytest.approx(result["chroma"])
37
+
38
+
39
+ def percussion(times, length=18):
40
+ audio = np.zeros((1, SR * length), dtype=np.float32)
41
+ rng = np.random.default_rng(17)
42
+ for i, start in enumerate(times):
43
+ t = np.arange(int(0.08 * SR)) / SR
44
+ # Low-frequency kicks alternate with mid-band noise snares. Unfiltered
45
+ # white noise is high-frequency dominated and represents a hat instead.
46
+ noise = np.convolve(rng.normal(0, 0.3, len(t)), np.ones(5) / 5, mode="same")
47
+ sound = (np.sin(2 * np.pi * 75 * t) if i % 2 == 0 else noise) * np.exp(-t * 55)
48
+ pos = int(start * SR)
49
+ audio[0, pos:pos + len(t)] += sound
50
+ return audio
51
+
52
+
53
+ def test_stable_tempo_streamlines_jitter_without_inventing_a_melody():
54
+ times = [0.3 + i * 0.5 + (0.012 if i % 3 == 0 else -0.009) for i in range(32)]
55
+ result = transcribe(percussion(times), SR, mode="drums")
56
+ assert result["tempo"]["bpm"] == pytest.approx(120, abs=2)
57
+ assert len(result["tempo"]["segments"]) == 1
58
+ assert result["melody"] == []
59
+ assert {n["pitch"] for n in result["drums"]} >= {36, 38}
60
+ assert len(result["drums"]) == pytest.approx(32, abs=2)
61
+ assert all(n["beat"] * 4 == round(n["beat"] * 4) for n in result["drums"])
62
+
63
+
64
+ def test_sustained_large_tempo_change_is_kept_but_manual_bpm_stays_fixed():
65
+ times = list(np.arange(0.3, 8.3, 0.6)) + list(np.arange(8.3, 24.3, 0.4))
66
+ audio = percussion(times, length=25)
67
+ result = transcribe(audio, SR, mode="drums")
68
+ bpms = [s["bpm"] for s in result["tempo"]["segments"]]
69
+ assert len(bpms) >= 2
70
+ assert any(abs(b - 100) < 4 for b in bpms)
71
+ assert any(abs(b - 150) < 4 for b in bpms)
72
+ fixed = transcribe(audio, SR, mode="drums", bpm=110)
73
+ assert [s["bpm"] for s in fixed["tempo"]["segments"]] == [110]
74
+
75
+
76
+ def test_silence_and_unreliable_tempo_do_not_make_up_music():
77
+ with pytest.raises(ValueError, match="No audible content"):
78
+ transcribe(np.zeros((1, SR)), SR, bpm=120)
79
+ with pytest.raises(ValueError, match="Supply bpm"):
80
+ transcribe(tone(60, 1)[None, :], SR, mode="melody")
81
+ result = transcribe(tone(60, 1)[None, :], SR, bpm=100)
82
+ assert [n["pitch"] for n in result["melody"]] == [60]
83
+ assert result["drums"] == []
84
+
85
+
86
+ def test_mixed_voice_keeps_a_pitched_layer_and_overlapping_percussion():
87
+ audio = percussion(np.arange(0.2, 6.2, 0.5), length=7)
88
+ for start, pitch in [(0.6, 57), (2.6, 62), (4.6, 60)]:
89
+ sound = tone(pitch, 1.2)
90
+ pos = int(start * SR)
91
+ audio[0, pos:pos + len(sound)] += sound
92
+ result = transcribe(audio, SR, bpm=120)
93
+ assert {n["pitch"] for n in result["melody"]} >= {57, 60, 62}
94
+ assert len(result["drums"]) >= 6
95
+ assert any(m["time"] <= d["time"] < m["time"] + m["duration"]
96
+ for m in result["melody"] for d in result["drums"])
97
+
98
+
99
+ @pytest.mark.parametrize("mode", ["auto", "melody"])
100
+ def test_same_pitch_syllables_keep_short_silent_gaps(mode):
101
+ audio = np.zeros((1, SR * 3), dtype=np.float32)
102
+ starts = [0.2, 0.7, 1.2, 1.7, 2.2]
103
+ for start in starts:
104
+ sound = tone(60, 0.48)
105
+ pos = int(start * SR)
106
+ audio[0, pos:pos + len(sound)] = sound
107
+ result = transcribe(audio, SR, mode=mode, bpm=120)
108
+ assert [n["pitch"] for n in result["melody"]] == [60] * 5
109
+ assert [n["time"] for n in result["melody"]] == pytest.approx(starts, abs=0.03)
110
+ assert [n["beat"] for n in result["melody"]] == [0, 1, 2, 3, 4]
111
+
112
+
113
+ def test_percussion_attacks_do_not_rearticulate_a_held_hummed_note():
114
+ audio = np.zeros((1, SR * 3), dtype=np.float32)
115
+ sound = tone(60, 2.6)
116
+ audio[0, int(0.2 * SR):int(0.2 * SR) + len(sound)] = sound
117
+ burst_times = [0.7, 1.2, 1.7, 2.2]
118
+ t = np.arange(int(0.04 * SR)) / SR
119
+ burst = 0.65 * np.sin(2 * np.pi * 6000 * t) * np.exp(-t * 60)
120
+ for start in burst_times:
121
+ pos = int(start * SR)
122
+ audio[0, pos:pos + len(burst)] += burst
123
+ result = transcribe(audio, SR, bpm=120)
124
+ assert [n["pitch"] for n in result["melody"]] == [60]
125
+ assert result["melody"][0]["duration"] == pytest.approx(2.6, abs=0.1)
126
+ assert [n["time"] for n in result["drums"]] == pytest.approx(burst_times, abs=0.05)
127
+
128
+
129
+ def test_rpc_validates_range_before_reading_and_reports_file_times(tmp_path):
130
+ import soundfile as sf
131
+ path = tmp_path / "sketch.wav"
132
+ sf.write(path, np.r_[np.zeros(SR), tone(62, 1), np.zeros(SR)], SR)
133
+ request = {"jsonrpc": "2.0", "id": 7, "method": "transcribe_voice_sketch",
134
+ "params": {"path": str(path), "startTime": 1, "endTime": 2, "bpm": 100, "mode": "melody"}}
135
+ result = json.loads(dispatch(json.dumps(request)))
136
+ assert result["result"]["startTime"] == 1
137
+ assert result["result"]["melody"][0]["time"] >= 1
138
+ assert result["result"]["melody"][0]["pitch"] == 62
139
+ request["params"]["endTime"] = 200
140
+ assert "120" in json.loads(dispatch(json.dumps(request)))["error"]["message"]