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.
233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
"""CLI to run ReaScript refinement on a .rpp file.
|
|
|
|
Usage: python scripts/run_in_reaper.py <rpp_path> [--output <wav_path>] [--timeout <seconds>]
|
|
[--plugins-config <song_json_path>] [--action <action1 action2 ...>]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
|
|
from src.reaper_scripting import ReaScriptGenerator
|
|
from src.reaper_scripting.commands import (
|
|
ReaScriptCommand,
|
|
ReaScriptResult,
|
|
read_result,
|
|
write_command,
|
|
)
|
|
from src.reaper_builder import get_builtin_plugins
|
|
from src.core.schema import (
|
|
SongDefinition,
|
|
SongMeta,
|
|
TrackDef,
|
|
PluginDef,
|
|
)
|
|
|
|
|
|
def _load_song_config(path: Path) -> SongDefinition:
|
|
"""Reconstruct a SongDefinition from a JSON file (produced by SongDefinition.to_json)."""
|
|
data: dict = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
meta_data: dict = data["meta"]
|
|
meta = SongMeta(
|
|
bpm=float(meta_data["bpm"]),
|
|
key=str(meta_data["key"]),
|
|
title=str(meta_data.get("title", "")),
|
|
ppq=int(meta_data.get("ppq", 960)),
|
|
time_sig_num=int(meta_data.get("time_sig_num", 4)),
|
|
time_sig_den=int(meta_data.get("time_sig_den", 4)),
|
|
calibrate=bool(meta_data.get("calibrate", True)),
|
|
)
|
|
|
|
tracks: list[TrackDef] = []
|
|
for t in data.get("tracks", []):
|
|
plugins: list[PluginDef] = []
|
|
for p in t.get("plugins", []):
|
|
params: dict[int, float] = {}
|
|
for k, v in p.get("params", {}).items():
|
|
params[int(k)] = float(v)
|
|
plugins.append(PluginDef(
|
|
name=str(p["name"]),
|
|
path=str(p.get("path", "")),
|
|
index=int(p.get("index", 0)),
|
|
params=params,
|
|
preset_data=p.get("preset_data"),
|
|
role=str(p.get("role", "")),
|
|
builtin=bool(p.get("builtin", False)),
|
|
))
|
|
tracks.append(TrackDef(
|
|
name=str(t["name"]),
|
|
volume=float(t.get("volume", 0.85)),
|
|
pan=float(t.get("pan", 0.0)),
|
|
color=int(t.get("color", 0)),
|
|
plugins=plugins,
|
|
send_reverb=float(t.get("send_reverb", 0.0)),
|
|
send_delay=float(t.get("send_delay", 0.0)),
|
|
send_level={int(k): float(v) for k, v in t.get("send_level", {}).items()},
|
|
))
|
|
|
|
return SongDefinition(meta=meta, tracks=tracks)
|
|
|
|
|
|
def _normalize_action(action_arg: str | None) -> list[str]:
|
|
"""Parse space-separated action names into a list. Defaults to ['calibrate']."""
|
|
if not action_arg:
|
|
return ["calibrate"]
|
|
return [a.strip() for a in action_arg.split() if a.strip()]
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Run ReaScript refinement on a .rpp file"
|
|
)
|
|
parser.add_argument("rpp_path", help="Path to .rpp file")
|
|
parser.add_argument(
|
|
"--output", "-o", help="Path for rendered WAV output", default=None
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=120,
|
|
help="Timeout in seconds for polling result (default: 120)",
|
|
)
|
|
parser.add_argument(
|
|
"--plugins-config",
|
|
help="Path to JSON serialized SongDefinition for deriving plugins_to_add",
|
|
default=None,
|
|
)
|
|
parser.add_argument(
|
|
"--action",
|
|
help="Space-separated action pipeline (e.g. 'add_plugins calibrate render'). Default: calibrate",
|
|
default=None,
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
rpp_path = Path(args.rpp_path)
|
|
if not rpp_path.exists():
|
|
print(f"ERROR: .rpp file not found: {rpp_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Resolve render_path: default to .rpp folder with _rendered.wav
|
|
if args.output:
|
|
render_path = Path(args.output)
|
|
else:
|
|
render_path = rpp_path.parent / f"{rpp_path.stem}_rendered.wav"
|
|
|
|
# Normalize action to list
|
|
actions = _normalize_action(args.action)
|
|
|
|
# Derive plugins_to_add from song if --plugins-config is provided
|
|
plugins_to_add: list[dict] = []
|
|
if args.plugins_config:
|
|
config_path = Path(args.plugins_config)
|
|
if not config_path.exists():
|
|
print(f"ERROR: plugins config file not found: {config_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
song = _load_song_config(config_path)
|
|
plugins_to_add = get_builtin_plugins(song)
|
|
print(f"Derived {len(plugins_to_add)} builtin plugins from song config")
|
|
|
|
# 1. Build command
|
|
command = ReaScriptCommand(
|
|
version=1,
|
|
action=actions,
|
|
rpp_path=str(rpp_path.resolve()),
|
|
render_path=str(render_path.resolve()),
|
|
timeout=args.timeout,
|
|
track_calibration=[],
|
|
plugins_to_add=plugins_to_add,
|
|
)
|
|
|
|
# 2. Generate ReaScript
|
|
generator = ReaScriptGenerator()
|
|
# Write to a fixed path - in real usage this would be REAPER ResourcePath()/scripts/
|
|
reaper_scripts_dir = Path(sys.path[1]) / "scripts" if len(sys.path) > 1 else Path("scripts")
|
|
script_path = reaper_scripts_dir / "fl_control_phase2.py"
|
|
generator.generate(script_path, command)
|
|
print(f"Generated ReaScript: {script_path}")
|
|
|
|
# 3. Write command JSON (same directory as script)
|
|
cmd_json_path = reaper_scripts_dir / "fl_control_command.json"
|
|
write_command(cmd_json_path, command)
|
|
print(f"Wrote command JSON: {cmd_json_path}")
|
|
|
|
# 4. Poll for result JSON
|
|
res_json_path = reaper_scripts_dir / "fl_control_result.json"
|
|
timeout = args.timeout
|
|
poll_interval = 2.0
|
|
elapsed = 0.0
|
|
|
|
print(f"Polling for result at {res_json_path} (timeout={timeout}s)...")
|
|
while elapsed < timeout:
|
|
if res_json_path.exists():
|
|
break
|
|
time.sleep(poll_interval)
|
|
elapsed += poll_interval
|
|
|
|
if not res_json_path.exists():
|
|
print("TIMEOUT: result JSON not found within timeout", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
# 5. Read and print results
|
|
try:
|
|
result = read_result(res_json_path)
|
|
except Exception as e:
|
|
print(f"ERROR: failed to read result: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if result.status == "error":
|
|
print(f"REAPER error: {result.message}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Print LUFS metrics
|
|
print("\n=== Phase 2 Result ===")
|
|
print(f"Status: {result.status}")
|
|
if result.lufs is not None:
|
|
print(f"LUFS: {result.lufs}")
|
|
if result.integrated_lufs is not None:
|
|
print(f"Integrated LUFS: {result.integrated_lufs}")
|
|
if result.short_term_lufs is not None:
|
|
print(f"Short-term LUFS: {result.short_term_lufs}")
|
|
print(f"Tracks verified: {result.tracks_verified}")
|
|
if result.fx_errors:
|
|
print(f"FX errors: {json.dumps(result.fx_errors, indent=2)}")
|
|
if result.added_plugins:
|
|
print(f"Added plugins: {json.dumps(result.added_plugins, indent=2)}")
|
|
print(f"Message: {result.message}")
|
|
|
|
# 6. Write output files
|
|
output_dir = rpp_path.parent / "output"
|
|
output_dir.mkdir(exist_ok=True)
|
|
|
|
# LUFS metrics
|
|
lufs_data = {
|
|
"lufs": result.lufs,
|
|
"integrated_lufs": result.integrated_lufs,
|
|
"short_term_lufs": result.short_term_lufs,
|
|
"render_path": str(render_path.resolve()),
|
|
}
|
|
lufs_path = output_dir / f"{rpp_path.stem}_lufs.json"
|
|
with open(lufs_path, "w", encoding="utf-8") as f:
|
|
json.dump(lufs_data, f, indent=2)
|
|
print(f"Wrote LUFS data: {lufs_path}")
|
|
|
|
# FX errors
|
|
if result.fx_errors:
|
|
fx_path = output_dir / f"{rpp_path.stem}_fx_errors.json"
|
|
with open(fx_path, "w", encoding="utf-8") as f:
|
|
json.dump(result.fx_errors, f, indent=2)
|
|
print(f"Wrote FX errors: {fx_path}")
|
|
|
|
print("\nPhase 2 complete.")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|