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.
6.7 KiB
Design: ReaScript-First Built-in Plugin Configuration
Technical Approach
Hybrid pipeline: RPPBuilder writes skeleton .rpp (VST3 + MIDI + audio only). Built-in plugins (ReaEQ, ReaComp, etc.) are deferred to ReaScript which inserts via TrackFX_AddByName and configures via TrackFX_SetParam. ReaScriptGenerator._build_script() produces dispatch-based main() that iterates cmd["action"] (now str | list[str]) and calls per-action functions conditionally. Backward compat: action="calibrate" still works.
Architecture Decisions
Multi-Action Dispatch Model
| Option | Tradeoff | Decision |
|---|---|---|
action: str | list[str] |
Normalization in Python; trivial backward compat | Chosen |
| Pipeline object with stages | Type-safe but adds dataclass overhead; overkill for 5 actions | Rejected |
| Separate command per action | Multiple script invocations (5x slower); breaks single-script model | Rejected |
Rationale: _build_script() normalizes str → [str]. Generated main() loops actions and calls per-action functions (add_plugins(), configure_fx_params(), verify_fx(), calibrate(), render()). Each function is emitted only if its action appears in the list.
Built-in FX Parameter Mapping
| Option | Tradeoff | Decision |
|---|---|---|
Index-based (dict[int, float]) |
Stable across REAPER versions; matches existing PluginDef.params |
Chosen |
Name-based (dict[str, float]) |
User-friendly but fragile (band labels change); requires lookup table | Rejected |
| Hybrid index + name aliases | Too complex for v1; doubles code | Rejected |
Rationale: Codebase already uses PluginDef.params: dict[int, float] (schema.py:169) and RPPBuilder populates param slots by index (lines 1828-1831). JSON uses {"2": 200.0} for transport (string keys). No mapping layer needed.
Command JSON Protocol Evolution
| Option | Tradeoff | Decision |
|---|---|---|
| Extend existing dataclasses with optional fields, keep v=1 | Zero breaking changes; all new fields optional | Chosen |
| New schema v2 | Clear boundary but breaks 298 existing tests | Rejected |
| Separate plugin command file | Coordination complexity between two JSON files | Rejected |
Rationale: plugins_to_add and added_plugins are additive optional fields. write_command / read_result serialize them only when present. Old scripts without plugins_to_add produce identical JSON.
Data Flow
compose.py ──→ SongDefinition ──→ RPPBuilder.write()
│ PluginDef.builtin=True │
│ (index-based params) ▼
│ skeleton .rpp (VST3 only)
│ │
│ ▼
│ run_in_reaper.py
│ │ builds ReaScriptCommand
│ │ ▸ action=["add_plugins","configure_fx_params",
│ │ "verify_fx","calibrate","render"]
│ │ ▸ plugins_to_add=[{track_name, fx_name, params}]
│ ▼
│ ReaScriptGenerator.generate()
│ │ adaptive check_api(actions)
│ │ dispatch loop: add_plugins→configure→verify→calibrate→render
│ ▼
│ REAPER executes → rendered .wav
│ │
▼ ▼
final .rpp ReaScriptResult JSON
▸ added_plugins, lufs
File Changes
| File | Action | Description |
|---|---|---|
src/reaper_scripting/__init__.py |
Modify | Add _add_plugins_src(), _configure_fx_params_src(), _action_dispatch_src(). Refactor _build_script() to per-action functions + dispatch loop. Adaptive check_api(actions). |
src/reaper_scripting/commands.py |
Modify | ReaScriptCommand: action: str | list[str], plugins_to_add: list[dict]. ReaScriptResult: added_plugins: list[dict]. Update write_command/read_result. |
scripts/run_in_reaper.py |
Modify | Accept --plugins-config flag; derive plugins_to_add from SongDefinition + RPPBuilder built-in set. Normalize action to list. |
src/reaper_builder/__init__.py |
Modify | Add REAPER_BUILTINS: frozenset[str]. Expose get_builtin_plugins(song) → list[dict]. Skip VST element generation for built-in plugins in _build_fx_chain(). |
src/core/schema.py |
Modify | Add builtin: bool = False to PluginDef. |
tests/test_reaper_scripting.py |
Modify | Tests: multi-action dispatch output, plugins_to_add block, adaptive check_api, backward compat string action. |
Interfaces / Contracts
PluginDef (new field):
builtin: bool = False # True → deferred to ReaScript, not written to .rpp
ReaScriptCommand (modified):
action: str | list[str] = "calibrate" # string normalized to list in _build_script
plugins_to_add: list[dict] = field(default_factory=list)
# Each dict: {"track_name": str, "fx_name": str, "params": {"0": float, ...}}
ReaScriptResult (modified):
added_plugins: list[dict] = field(default_factory=list)
# Each dict: {"fx_name": str, "instance_id": int, "track_name": str, "status": str}
RPPBuilder (new export):
REAPER_BUILTINS: frozenset[str] # {"ReaEQ", "ReaComp", "ReaDelay", ...}
def get_builtin_plugins(song: SongDefinition) -> list[dict]: ...
Testing Strategy
| Layer | What | Approach |
|---|---|---|
| Unit | Multi-action dispatch generates correct code blocks | Parametrize action list variants; assert source contains expected per-action functions |
| Unit | Adaptive check_api requires FX APIs only when relevant |
Test with/without add_plugins; grep generated source for required API names |
| Unit | plugins_to_add serialization round-trip |
JSON write → read → assert optional fields survive |
| Integration | Built-in plugin detection in RPPBuilder | Add builtin=True to PluginDef; assert skipped in .rpp output, listed in command |
| Regression | All 298 existing tests pass unchanged | Run pytest before merge; string action backward compat verified |
Migration / Rollout
No data migration required. All new fields are optional with defaults. action="calibrate" produces identical script output. Rollback: revert commands.py and __init__.py changes; existing tests remain green.