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