Files
renato97 b08dcccca2 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.
2026-05-04 09:38:58 -03:00

115 lines
6.7 KiB
Markdown

# 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):
```python
builtin: bool = False # True → deferred to ReaScript, not written to .rpp
```
**ReaScriptCommand** (modified):
```python
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):
```python
added_plugins: list[dict] = field(default_factory=list)
# Each dict: {"fx_name": str, "instance_id": int, "track_name": str, "status": str}
```
**RPPBuilder** (new export):
```python
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.