feat: reascript-first — built-in REAPER plugin insertion via ReaScript API

Hybrid pipeline: RPPBuilder writes VST3/MIDI/audio skeleton, ReaScript
handles built-in plugins (ReaEQ, ReaComp) via TrackFX_AddByName +
TrackFX_SetParam with multi-action dispatch, adaptive API check, and
builtin plugin auto-detection from PLUGIN_REGISTRY.

326 tests (298 existing + 28 new), 12/12 spec scenarios compliant.
This commit is contained in:
renato97
2026-05-04 09:38:58 -03:00
parent 33bb08270d
commit b08dcccca2
11 changed files with 1470 additions and 83 deletions

View File

@@ -20,6 +20,17 @@ from src.reaper_scripting.commands import (
write_command,
)
from src.reaper_scripting import ReaScriptGenerator
from src.core.schema import (
SongDefinition,
SongMeta,
TrackDef,
PluginDef,
)
from src.reaper_builder import (
RPPBuilder,
REAPER_BUILTINS,
get_builtin_plugins,
)
# ------------------------------------------------------------------
@@ -302,3 +313,515 @@ class TestReaScriptGeneratorOutput:
source = path.read_text(encoding="utf-8")
assert "def main():" in source
assert "main()" in source
# ------------------------------------------------------------------
# Phase 4: Multi-Action Dispatch, Adaptive API, JSON Round-Trip
# ------------------------------------------------------------------
class TestMultiActionDispatch:
"""4.1 Multi-action dispatch generates correct per-action functions."""
@pytest.mark.parametrize("action_list,expected_funcs,unexpected_funcs", [
# calibrate only → calibrate function present, render/add_plugins NOT emitted
(["calibrate"], ["def calibrate("], ["def add_plugins(", "def configure_fx_params(", "def do_render("]),
# add_plugins only → add_plugins and find_track present, calibrate/render NOT
(["add_plugins"], ["def add_plugins(", "def find_track("], ["def calibrate(", "def configure_fx_params(", "def do_render("]),
# add_plugins + verify_fx → both present, calibrate/render NOT
(["add_plugins", "verify_fx"], ["def add_plugins(", "def verify_fx(", "def find_track("], ["def calibrate(", "def do_render("]),
# full pipeline → all 4 action functions present
(["add_plugins", "configure_fx_params", "calibrate", "render"],
["def add_plugins(", "def configure_fx_params(", "def calibrate(", "def do_render("], []),
])
def test_per_action_functions_conditional(self, tmp_path, action_list, expected_funcs, unexpected_funcs):
cmd = ReaScriptCommand(
version=1,
action=action_list,
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
for func in expected_funcs:
assert func in source, f"Expected '{func}' in output for actions={action_list}"
for func in unexpected_funcs:
assert func not in source, f"Unexpected '{func}' in output for actions={action_list}"
def test_string_action_backward_compat(self, tmp_path):
"""String action 'calibrate' produces same output as list ['calibrate']."""
# Generate with string
cmd_str = ReaScriptCommand(
version=1,
action="calibrate",
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path_str = tmp_path / "str_action.py"
gen = ReaScriptGenerator()
gen.generate(path_str, cmd_str)
source_str = path_str.read_text(encoding="utf-8")
# Generate with list
cmd_list = ReaScriptCommand(
version=1,
action=["calibrate"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path_list = tmp_path / "list_action.py"
gen.generate(path_list, cmd_list)
source_list = path_list.read_text(encoding="utf-8")
# Both should be identical
assert source_str == source_list, (
"String action 'calibrate' should produce same output as list ['calibrate']"
)
def test_empty_action_defaults_to_calibrate(self, tmp_path):
"""Empty action list defaults to ['calibrate']."""
cmd = ReaScriptCommand(
version=1,
action=[],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
assert "def calibrate(" in source
# Render functions are NOT emitted since action defaults to ["calibrate"] only
assert "def do_render(" not in source
def test_dispatch_loop_present_with_multiple_actions(self, tmp_path):
"""When multiple actions present, dispatch loop iterates them."""
cmd = ReaScriptCommand(
version=1,
action=["add_plugins", "verify_fx", "calibrate"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
# Each action should appear in the dispatch if/elif chain
assert '"add_plugins"' in source
assert '"verify_fx"' in source
assert '"calibrate"' in source
# action_list variable exists
assert "action_list" in source
class TestAdaptiveApiCheck:
"""4.2 Adaptive check_api requires FX APIs only with add_plugins."""
def test_check_api_requires_fx_apis_with_add_plugins(self, tmp_path):
cmd = ReaScriptCommand(
version=1,
action=["add_plugins"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
assert "TrackFX_AddByName" in source
assert "TrackFX_SetParam" in source
assert "check_api" in source
def test_check_api_requires_fx_apis_with_configure_fx_params(self, tmp_path):
cmd = ReaScriptCommand(
version=1,
action=["add_plugins", "configure_fx_params"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
assert "TrackFX_AddByName" in source
assert "TrackFX_SetParam" in source
def test_check_api_omits_fx_apis_for_calibrate_only(self, tmp_path):
"""String 'calibrate' (backward compat) should NOT include FX APIs."""
cmd = ReaScriptCommand(
version=1,
action="calibrate",
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
# Verify check_api exists but does NOT include FX APIs
assert "check_api" in source
# The check_api function defines required list; TrackFX_AddByName should NOT be in that list
# Find the check_api function body
check_api_start = source.find("def check_api():")
check_api_end = source.find("def ", check_api_start + 1)
if check_api_end == -1:
check_api_section = source[check_api_start:]
else:
check_api_section = source[check_api_start:check_api_end]
assert "TrackFX_AddByName" not in check_api_section
assert "TrackFX_SetParam" not in check_api_section
def test_check_api_includes_fx_apis_with_add_plugins_in_list(self, tmp_path):
"""When add_plugins is in the action list, FX APIs are required."""
cmd = ReaScriptCommand(
version=1,
action=["add_plugins", "calibrate"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
)
path = tmp_path / "phase2.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
check_api_start = source.find("def check_api():")
check_api_end = source.find("def ", check_api_start + 1)
if check_api_end == -1:
check_api_section = source[check_api_start:]
else:
check_api_section = source[check_api_start:check_api_end]
assert "TrackFX_AddByName" in check_api_section
assert "TrackFX_SetParam" in check_api_section
class TestPluginsToAddRoundTrip:
"""4.3 plugins_to_add/added_plugins JSON round-trip serialization."""
def test_write_read_roundtrip_plugins_to_add(self, tmp_path):
"""Write command with plugins_to_add, read back, verify fields survive."""
cmd = ReaScriptCommand(
version=1,
action=["add_plugins", "configure_fx_params", "calibrate"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
plugins_to_add=[
{"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0, "5": 3.0}},
{"track_name": "Lead", "fx_name": "ReaComp", "params": {"0": -18.0}},
],
)
cmd_path = tmp_path / "cmd.json"
write_command(cmd_path, cmd)
# Read back raw JSON
raw = json.loads(cmd_path.read_text(encoding="utf-8"))
assert "plugins_to_add" in raw
assert len(raw["plugins_to_add"]) == 2
assert raw["plugins_to_add"][0]["track_name"] == "Bass"
assert raw["plugins_to_add"][0]["fx_name"] == "ReaEQ"
assert raw["plugins_to_add"][0]["params"]["2"] == 200.0
def test_read_result_includes_added_plugins(self, tmp_path):
"""Read a result JSON with added_plugins field."""
data = {
"version": 1,
"status": "ok",
"message": "",
"fx_errors": [],
"tracks_verified": 2,
"added_plugins": [
{"fx_name": "ReaEQ", "instance_id": 0, "track_name": "Bass", "status": "ok"},
{"fx_name": "ReaComp", "instance_id": 0, "track_name": "Lead", "status": "ok"},
],
}
res_path = tmp_path / "result.json"
res_path.write_text(json.dumps(data), encoding="utf-8")
result = read_result(res_path)
assert len(result.added_plugins) == 2
assert result.added_plugins[0]["fx_name"] == "ReaEQ"
assert result.added_plugins[0]["instance_id"] == 0
assert result.added_plugins[1]["status"] == "ok"
def test_write_command_omits_empty_plugins_to_add(self, tmp_path):
"""When plugins_to_add is empty, it should NOT appear in JSON."""
cmd = ReaScriptCommand(
version=1,
action="calibrate",
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
plugins_to_add=[],
)
cmd_path = tmp_path / "cmd.json"
write_command(cmd_path, cmd)
raw = json.loads(cmd_path.read_text(encoding="utf-8"))
assert "plugins_to_add" not in raw
def test_read_result_handles_missing_added_plugins(self, tmp_path):
"""Old result JSON without added_plugins should default to empty list."""
data = {
"version": 1,
"status": "ok",
"message": "",
"fx_errors": [],
"tracks_verified": 0,
}
res_path = tmp_path / "result.json"
res_path.write_text(json.dumps(data), encoding="utf-8")
result = read_result(res_path)
assert result.added_plugins == []
class TestBuiltinPluginSkipping:
"""4.4 builtin=True PluginDef skipped in RPP .rpp output, listed in command."""
def test_builtin_plugin_not_in_rpp_output(self, tmp_path):
"""A PluginDef with builtin=True is excluded from .rpp VST elements."""
song = SongDefinition(
meta=SongMeta(bpm=95, key="Am", title="Test Builtin"),
tracks=[
TrackDef(
name="Bass",
volume=0.85,
plugins=[
PluginDef(
name="ReaEQ",
path="VST: ReaEQ (Cockos)",
index=0,
params={2: 200.0, 5: 3.0},
builtin=True,
),
PluginDef(
name="Serum_2",
path="VST3i: Serum 2 (Xfer Records)",
index=1,
builtin=False,
),
],
),
],
)
builder = RPPBuilder(song)
output_path = tmp_path / "test_builtin.rpp"
builder.write(output_path)
rpp_content = output_path.read_text(encoding="utf-8")
# ReaEQ (builtin=True) should NOT appear in the VST chain
assert "ReaEQ" not in rpp_content, (
"Builtin plugin ReaEQ should NOT be written to .rpp VST section"
)
# Serum_2 (builtin=False) SHOULD appear
assert "Serum 2" in rpp_content, (
"Non-builtin plugin Serum 2 SHOULD be written to .rpp"
)
def test_builtin_plugin_in_get_builtin_plugins(self):
"""get_builtin_plugins() returns builtin=True plugins for the command."""
song = SongDefinition(
meta=SongMeta(bpm=95, key="Am", title="Test Builtin"),
tracks=[
TrackDef(
name="Bass",
plugins=[
PluginDef(
name="ReaEQ",
path="VST: ReaEQ (Cockos)",
index=0,
params={2: 200.0, 5: 3.0},
builtin=True,
),
PluginDef(
name="ReaComp",
path="VST: ReaComp (Cockos)",
index=1,
params={0: -18.0},
builtin=False,
),
],
),
],
)
builtins = get_builtin_plugins(song)
# ReaEQ has builtin=True → should appear
assert len(builtins) >= 1
reaeq_entries = [b for b in builtins if b["fx_name"] == "ReaEQ"]
assert len(reaeq_entries) == 1
assert reaeq_entries[0]["track_name"] == "Bass"
# ReaComp with builtin=False BUT name in REAPER_BUILTINS → should also appear
assert "ReaComp" in REAPER_BUILTINS
reacomp_entries = [b for b in builtins if b["fx_name"] == "ReaComp"]
assert len(reacomp_entries) == 1
def test_non_builtin_not_in_reaper_builtins(self):
"""A non-builtin, non-Cockos plugin name is NOT in REAPER_BUILTINS."""
assert "Serum_2" not in REAPER_BUILTINS
assert "Omnisphere" not in REAPER_BUILTINS
# But Cockos plugins ARE
assert "ReaEQ" in REAPER_BUILTINS
assert "ReaComp" in REAPER_BUILTINS
def test_params_survive_in_get_builtin_plugins(self):
"""Plugin params are correctly converted to string-keyed dict."""
song = SongDefinition(
meta=SongMeta(bpm=95, key="Am", title="Test Params"),
tracks=[
TrackDef(
name="Bass",
plugins=[
PluginDef(
name="ReaEQ",
path="VST: ReaEQ (Cockos)",
index=0,
params={2: 200.0, 5: 3.0},
builtin=True,
),
],
),
],
)
builtins = get_builtin_plugins(song)
reaeq = builtins[0]
assert reaeq["params"] == {"2": 200.0, "5": 3.0}
# ------------------------------------------------------------------
# Phase 5: Generated Code Content Assertions
# ------------------------------------------------------------------
class TestAddPluginsSourceContent:
"""Verify add_plugins generated source contains correct ReaScript API calls."""
@pytest.fixture
def source_with_add_plugins(self, tmp_path: Path) -> str:
"""Generate script with add_plugins action and return source."""
cmd = ReaScriptCommand(
version=1,
action=["add_plugins"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
plugins_to_add=[
{"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0}},
],
)
path = tmp_path / "content_test.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
return path.read_text(encoding="utf-8")
def test_add_plugins_calls_trackfx_addbyname(self, source_with_add_plugins: str):
"""Insert ReaEQ on target track — TrackFX_AddByName is called."""
assert "RPR_TrackFX_AddByName" in source_with_add_plugins
def test_add_plugins_handles_track_not_found(self, source_with_add_plugins: str):
"""Track not found — error written, script continues."""
assert "track not found" in source_with_add_plugins.lower()
assert "continue" in source_with_add_plugins
def test_add_plugins_verifies_with_getfxname(self, source_with_add_plugins: str):
"""Plugin loaded successfully — GetFXName case-insensitive match."""
assert "RPR_TrackFX_GetFXName" in source_with_add_plugins
assert ".lower()" in source_with_add_plugins # case-insensitive comparison
def test_add_plugins_records_ok_status(self, source_with_add_plugins: str):
"""Plugin loaded — 'ok' status recorded in results."""
assert '"status": "ok"' in source_with_add_plugins
def test_add_plugins_records_failed_status(self, source_with_add_plugins: str):
"""Plugin failed to load — 'failed to load' status recorded."""
assert "failed to load" in source_with_add_plugins
class TestConfigureFxParamsSourceContent:
"""Verify configure_fx_params generated source contains correct parameter setting."""
@pytest.fixture
def source_with_configure(self, tmp_path: Path) -> str:
"""Generate script with configure_fx_params action and return source."""
cmd = ReaScriptCommand(
version=1,
action=["add_plugins", "configure_fx_params"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
plugins_to_add=[
{"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0, "5": 3.0}},
],
)
path = tmp_path / "content_test.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
return path.read_text(encoding="utf-8")
def test_configure_calls_setparam(self, source_with_configure: str):
"""Set ReaEQ frequency and gain — TrackFX_SetParam called."""
assert "RPR_TrackFX_SetParam" in source_with_configure
def test_configure_iterates_params_dict(self, source_with_configure: str):
"""Iterates params dict, converts string keys to int indices."""
assert "int(param_idx_str)" in source_with_configure
def test_configure_finds_fx_by_name(self, source_with_configure: str):
"""Looks up FX by name to get target index before setting params."""
assert "RPR_TrackFX_GetCount" in source_with_configure
assert "RPR_TrackFX_GetFXName" in source_with_configure
class TestNoBareExcept:
"""Verify generated script template has no bare except clauses."""
def test_no_bare_except_in_generated_source(self, tmp_path: Path):
"""Generated ReaScript must not contain bare 'except:' (only 'except ...')."""
cmd = ReaScriptCommand(
version=1,
action=["add_plugins", "configure_fx_params", "calibrate", "render"],
rpp_path="C:/song.rpp",
render_path="C:/song.wav",
timeout=120,
track_calibration=[],
plugins_to_add=[
{"track_name": "Bass", "fx_name": "ReaEQ", "params": {"2": 200.0}},
],
)
path = tmp_path / "no_bare_except.py"
gen = ReaScriptGenerator()
gen.generate(path, cmd)
source = path.read_text(encoding="utf-8")
# Check no bare "except:" (without exception type)
lines = source.split("\n")
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("except:") and len(stripped) == len("except:"):
pytest.fail(
f"Bare 'except:' found at line {i + 1}: {line}"
)