# Reaper Scripting Module

> Parent doc: [LLM_CONTEXT.md](../LLM_CONTEXT.md)

## Purpose

Generates self-contained Python ReaScript files that REAPER executes for post-processing: plugin loading, FX parameter configuration, mix calibration, verification, and rendering. Communicates via JSON files on disk.

**Location**: `src/reaper_scripting/`

## Public API

### ReaScriptGenerator (`__init__.py`)

```python
class ReaScriptGenerator:
    def generate(self, path: Path, command: ReaScriptCommand) -> None
```

- **`generate()`**: Writes a self-contained Python ReaScript to `path`. The script opens the `.rpp` file, executes the action pipeline, and writes results to `fl_control_result.json`.
- **Internal**: `_build_script(command)` assembles the full script source, conditionally including only the action functions needed by the command.

### ReaScriptCommand (`commands.py`)

```python
@dataclass
class ReaScriptCommand:
    version: int = 1
    action: str | list[str] = "calibrate"
    rpp_path: str = ""
    render_path: str = ""
    timeout: int = 120
    track_calibration: list[dict] = field(default_factory=list)
    plugins_to_add: list[dict] = field(default_factory=list)
```

### ReaScriptResult (`commands.py`)

```python
@dataclass
class ReaScriptResult:
    version: int = 1
    status: str = "ok"
    message: str = ""
    lufs: float | None = None
    integrated_lufs: float | None = None
    short_term_lufs: float | None = None
    fx_errors: list[dict] = field(default_factory=list)
    tracks_verified: int = 0
    added_plugins: list[dict] = field(default_factory=list)
```

### ProtocolVersionError (`commands.py`)

```python
class ProtocolVersionError(Exception):
    """Raised when command/result version doesn't match expected."""
```

### Serialization Functions (`commands.py`)

```python
def write_command(path: Path, cmd: ReaScriptCommand) -> None
def read_result(path: Path, expected_version: int = 1) -> ReaScriptResult
```

- **`write_command()`**: Serializes `ReaScriptCommand` to JSON file. Omits `plugins_to_add` key if empty.
- **`read_result()`**: Deserializes `ReaScriptResult` from JSON file. Raises `ProtocolVersionError` if version != expected_version.

## Data Flow

```
ReaScriptCommand
    │
    ▼
ReaScriptGenerator.generate(script_path, command)
    │  _build_script(command)
    │  ├── check_api() — verifies REAPER API function availability
    │  ├── parse_json() / write_json() — hand-rolled JSON (no stdlib json)
    │  ├── find_track() — lookup by name via RPR_GetSetMediaTrackInfo_String
    │  ├── add_plugins() — RPR_TrackFX_AddByName for built-in plugins
    │  ├── configure_fx_params() — RPR_TrackFX_SetParam for param config
    │  ├── calibrate() — RPR_SetMediaTrackInfo_Value (volume/pan) + sends
    │  ├── verify_fx() — enumerates all tracks/FX, reports missing
    │  ├── render() — RPR_Main_RenderFile for WAV export
    │  ├── measure_loudness() — RPR_CalcMediaSrcLoudness for LUFS
    │  └── main() — dispatch loop: read command → open project → actions → write result
    ▼
fl_control_phase2.py (self-contained ReaScript)
    │  (REAPER executes this)
    ▼
fl_control_result.json
    │
    ▼
read_result() → ReaScriptResult
```

## Command/Result Contract

### Command JSON (`fl_control_command.json`)

Written by `write_command()`:

```json
{
  "version": 1,
  "action": ["add_plugins", "calibrate", "render"],
  "rpp_path": "C:/path/to/song.rpp",
  "render_path": "C:/path/to/song_rendered.wav",
  "timeout": 120,
  "track_calibration": [
    {"track_index": 0, "volume": 0.85, "pan": 0.0, "sends": []}
  ],
  "plugins_to_add": [
    {"track_name": "808 Bass", "fx_name": "ReaEQ", "params": {"0": "1", "1": "0", "2": "300.0"}}
  ]
}
```

### Result JSON (`fl_control_result.json`)

Written by the ReaScript's `write_json()`:

```json
{
  "version": 1,
  "status": "ok",
  "message": "",
  "lufs": -14.2,
  "integrated_lufs": -14.2,
  "short_term_lufs": -13.8,
  "fx_errors": [],
  "tracks_verified": 10,
  "added_plugins": [
    {"fx_name": "ReaEQ", "instance_id": 0, "track_name": "808 Bass", "status": "ok"}
  ]
}
```

## Known Actions

| Action | Function | Description |
|--------|----------|-------------|
| `add_plugins` | `add_plugins(cmd)` | Loads plugins via `TrackFX_AddByName`. Returns per-plugin status. |
| `configure_fx_params` | `configure_fx_params(cmd)` | Sets FX parameters via `TrackFX_SetParam`. |
| `verify_fx` | `verify_fx()` | Enumerates all tracks/FX, reports empty/missing FX names. |
| `calibrate` | `calibrate(track_cal)` | Sets track volume, pan, and sends from calibration data. |
| `render` | `do_render(render_path)` + `measure_loudness()` | Renders WAV and measures LUFS via `RPR_CalcMediaSrcLoudness`. |

Actions execute in the order specified by the `action` list. The script's `main()` opens the project once, then runs the action dispatch loop, then writes the result JSON.

## ReaScript Architecture

The generated script is a single self-contained `.py` file with:

1. **Zero external imports**: No `json`, no `os`, no third-party libraries. Only REAPER's built-in `RPR_*` functions.
2. **Hand-rolled JSON parser**: ~100 lines of string-splitting logic (`parse_json()`). Handles strings, integers, floats, booleans, nulls, nested objects, arrays.
3. **API availability check**: `check_api()` verifies all required REAPER API functions exist before any work begins. Missing APIs → immediate error result.
4. **Conditional function inclusion**: Only the action functions needed by the specific command are emitted in the script. The generator checks `action` list and includes only relevant source blocks.
5. **Adaptive API check**: `check_api()` includes `TrackFX_AddByName` and `TrackFX_SetParam` only when `add_plugins` or `configure_fx_params` actions are present.

### REAPER API Functions Used

| API Function | Purpose |
|-------------|---------|
| `RPR_CountTracks` | Enumerate tracks |
| `RPR_GetTrack` | Get track by index |
| `RPR_GetSetMediaTrackInfo_String` | Get/set track name |
| `RPR_SetMediaTrackInfo_Value` | Set track volume (D_VOL), pan (D_PAN) |
| `RPR_TrackFX_GetCount` | Count FX on a track |
| `RPR_TrackFX_GetFXName` | Get FX name by index |
| `RPR_TrackFX_AddByName` | Add FX by display name |
| `RPR_TrackFX_SetParam` | Set FX parameter by index |
| `RPR_CreateTrackSend` | Create send between tracks |
| `RPR_Main_openProject` | Open .rpp project |
| `RPR_Main_RenderFile` | Render to file |
| `RPR_CalcMediaSrcLoudness` | Measure LUFS |
| `RPR_ResourcePath` | Get REAPER resource directory path |
| `RPR_GetFunctionMetadata` | Check API function availability |
| `RPR_GetLastError` | Get last error |
| `RPR_GetProjectName` | Get project name |

## Dependencies

- `src/reaper_scripting/commands.py` — `ReaScriptCommand`, `ReaScriptResult`, `write_command()`, `read_result()`
- `src/core/schema.py` — `SongDefinition`, `PluginDef` (indirect, via `get_builtin_plugins()`)
- `pathlib` (stdlib) — file path handling

## Known Gotchas

1. **REAPER Python has no `json` module**: The generated ReaScript cannot `import json`. All serialization uses the hand-rolled parser. This is a fundamental REAPER limitation — its embedded Python is a stripped-down distribution.

2. **Command JSON MUST be at the right path**: The ReaScript reads `fl_control_command.json` from `RPR_ResourcePath() + "scripts/"`. If the file isn't there when the script runs, it fails immediately.

3. **`write_command()` omits empty `plugins_to_add`**: If `plugins_to_add` is empty, the key is not written. The ReaScript's `cmd.get("plugins_to_add", [])` handles this with a default.

4. **Track name matching is case-insensitive**: `find_track()` normalizes both the search name and the REAPER track name to lowercase for matching. Track names that differ only by case will collide.

5. **`configure_fx_params` uses string keys for param indices**: The `params` dict in `plugins_to_add` uses string keys (`"0"`, `"1"`) because JSON keys are always strings. The ReaScript converts to `int` before calling `TrackFX_SetParam`.

6. **No error recovery per action**: If `add_plugins` fails for one track, it continues with the next. But if any action crashes (unhandled exception), the script terminates without writing the result JSON — leading to polling timeout in `run_in_reaper.py`.

7. **Calibrate sends use `CreateTrackSend`**: Every call to `calibrate()` with sends creates a new send connection. If calibrate is called multiple times, duplicate sends accumulate.

8. **LUFS is optional**: If `RPR_CalcMediaSrcLoudness` returns an unexpected format or throws, `measure_loudness()` returns `None` values silently.
