# Reaper Builder Module

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

## Purpose

Generates valid REAPER `.rpp` project files from a `SongDefinition`. Maintains the plugin registry (~97 VST2/VST3 plugins with GUIDs), builds the `.rpp` element tree, and supports headless rendering via REAPER CLI.

**Location**: `src/reaper_builder/`

## Public API

### RPPBuilder (`__init__.py`)

```python
class RPPBuilder:
    def __init__(self, song: SongDefinition, seed: int | None = None) -> None

    def write(self, path: str | Path) -> None
```

- **`__init__()`**: Receives a `SongDefinition` and optional seed. Seeds `random` for deterministic GUID generation when seed is not None.
- **`write()`**: Serializes the project to a `.rpp` text file. Raises `OSError` if file cannot be written. Uses the `rpp` library (`Element`, `dumps`) for XML-like structure.
- **Internal**: `_build_element()` → `_build_track(track)` → `_build_plugin(plugin)` → `_build_clip(clip)` → `_build_midi_source(clip)`.

### Plugin Registry (`__init__.py`)

```python
PLUGIN_REGISTRY: dict[str, tuple[str, str, str]]
```

Format: `"key": ("display_name", "filename", "uid_guid")`

~97 entries covering:
- **FabFilter**: Pro-Q 3, Pro-C 2, Pro-R 2, Pro-L 2, Saturn 2, Timeless 3, Pro-DS, Pro-G, Pro-MB, Micro, One, Simplon, Twin 3, Volcano 3 (both VST2 and VST3 versions)
- **SoundToys**: Decapitator, EchoBoy, Crystallizer, Devil-Loc, EffectRack, FilterFreak, MicroShift, PanMan, PhaseMistress, PrimalTap, Radiator, Tremolator, Little AlterBoy, Little MicroShift, Little PrimalTap, Little Radiator
- **iZotope Ozone 12**: Equalizer, Dynamics, Maximizer, plus 17 additional Ozone modules
- **Spectrasonics**: Omnisphere, FX-Omnisphere
- **Xfer**: Serum 2, Serum 2 FX
- **u-he**: Diva
- **Arturia**: Pigments
- **Cableguys**: ShaperBox 3
- **Native Instruments**: Kontakt 7, VC 160, VC 2A, VC 76
- **Cockos (REAPER native)**: ReaEQ, ReaComp, ReaDelay, ReaVerb, ReaVerbate, ReaFIR, ReaGate, ReaLimit, ReaPitch, ReaXcomp, ReaTune, ReaSynth, ReaSamplOmatic5000, and more (20+ plugins)
- **Other**: ValhallaDelay, Gullfoss (Standard/Master/Live), The Glue, Trackspacer 2.5, ravity, Tone2 Electra

```python
ALIAS_MAP: dict[str, str]
```

Maps alternate names to registry keys:
- `"Serum2"` → `"Serum_2"`
- `"FabFilter Pro-Q 3"` → `"Pro-Q_3"` (space-separated → underscore VST3 key)
- `"Pro-Q 3"` → `"Pro-Q_3"` (short name → VST3 key)
- `"Valhalla Delay"` → `"ValhallaDelay"`
- VST2 SoundToys old names → new underscore names

```python
REAPER_BUILTINS: frozenset[str]
```

Set of Cockos plugin keys. Plugins in this set are deferred to ReaScript insertion.

```python
def get_builtin_plugins(song: SongDefinition) -> list[dict[str, object]]
```

Extracts plugins where `PluginDef.builtin == True` or name is in `REAPER_BUILTINS`. Returns list of `{"track_name", "fx_name", "params"}` dicts for `ReaScriptCommand.plugins_to_add`.

### Preset Transformer (`preset_transformer.py`)

```python
# Transforms flat _PRESETS_FLAT dict → role-aware PLUGIN_PRESETS
# Format: {(plugin_key, role): [chunk1, chunk2, ...]}
PLUGIN_PRESETS: dict[tuple[str, str], list[str]]
```

Lookup chain: `(key, role)` → `(key, "")` (default) → `fallback` → `None`.

Plugins with preset data: Arcade, Omnisphere, Pro-Q_3, Pro-C_2, Pro-R_2, Pro-L_2, Saturn 2, Timeless 3, The Glue, ValhallaDelay, Serum 2, Diva, Kontakt 7, VC 160, VC 76, Gullfoss, Gullfoss Master, Decapitator, EchoBoy, EffectRack, FilterFreak1, MicroShift, Little AlterBoy, PhaseMistress, Tremolator.

Plugins with empty presets (no saved state): Most Ozone 12 modules, Pigments, ShaperBox 3, Gullfoss Live, Trackspacer 2.5, Crystallizer, FilterFreak2, Little MicroShift, Little PrimalTap, Little Radiator, PanMan, VC 2A, Elektra.

### Headless Render (`render.py`)

```python
def render_project(
    rpp_path: str | Path,
    output_wav: str | Path,
    reaper_exe: str | Path | None = None,
    timeout_seconds: int = 120,
) -> None
```

Renders a `.rpp` to WAV via `reaper.exe -nosplash -render`. Default REAPER path: `C:\Program Files\REAPER (x64)\reaper.exe`. Raises `FileNotFoundError` if REAPER not found, `RuntimeError` on render failure.

## Data Flow

```
SongDefinition
    │
    ▼
RPPBuilder.__init__(song, seed)
    │
    ▼
RPPBuilder.write(path)
    │  _build_element()
    │  ├── Project header (_PROJECT_HEADER static lines)
    │  ├── TEMPO line (dynamic from song.meta)
    │  ├── Master TRACK with FXCHAIN
    │  │   ├── master_plugins resolved via ALIAS_MAP → PLUGIN_REGISTRY
    │  │   └── VST elements with preset data
    │  ├── Per-track TRACK elements
    │  │   ├── NAME, VOLPAN (from TrackDef.volume/.pan), TRACKID
    │  │   ├── AUXRECV (from send_level dict)
    │  │   ├── FXCHAIN with VST elements (non-builtin plugins)
    │  │   └── ITEM elements (clips)
    │  │       ├── SOURCE WAVE (audio clips: FILE path)
    │  │       └── SOURCE MIDI (MIDI clips: E-lines at 960 PPQ)
    │  └── TEXT output → .rpp file
    ▼
output/song.rpp
```

## `.rpp` File Structure

The generated `.rpp` follows REAPER's ground-truth format extracted from `output/test_vst3.rpp`:

```rpp
<REAPER_PROJECT 0.1 "7.65/win64" ...>
  <NOTES ...>
  ...static project metadata...
  TEMPO 95 4 4 0
  <TRACK {master-guid}>
    NAME master
    ...
    <FXCHAIN>
      <VST "VST3: Pro-Q 3 (FabFilter)" FabFilter {GUID} ...>
      <VST "VST3: Pro-C 2 (FabFilter)" FabFilter {GUID} ...>
      <VST "VST3: Pro-L 2 (FabFilter)" FabFilter {GUID} ...>
    >
  >
  <TRACK {track-guid}>
    NAME "808 Bass"
    VOLPAN 0.820000 0.000000 -1 -1 1
    <FXCHAIN>
      <VST "VST3i: Serum 2 (Xfer Records)" Serum2.vst3 {GUID} ...>
    >
    AUXRECV 8 0.050000 -1 -1 0
    AUXRECV 9 0.000000 -1 -1 0
    <ITEM>
      POSITION 0.0
      LENGTH 32.0
      NAME "Verse 808"
      <SOURCE MIDI>
        HASDATA 1 960 QN
        E 0 90 21 50
        E 960 80 21 00
        ...
      >
    >
  >
  <TRACK {track-guid}>
    NAME "Reverb"
    ...
  >
  <TRACK {track-guid}>
    NAME "Delay"
    ...
  >
>
```

### VST Element Format

**VST3** (`.vst3` files with `{GUID}`):
```rpp
<VST "VST3: Pro-Q 3 (FabFilter)" FabFilter {72C4DB71...} 0 "" 1158812272...>
  base64chunk1
  base64chunk2
  ...
>
```

**VST2** (`.dll` files with `<GUID>`):
```rpp
<VST "VST: Decapitator (SoundToys)" Decapitator.dll 0 "" <56535453744463...> 0 "" ...>
  base64chunk1
  base64chunk2
  ...
>
```

### MIDI Source Format

MIDI data is written as E-lines at 960 PPQ with 16th-note quantization (120-tick grid):
```rpp
<SOURCE MIDI>
  HASDATA 1 960 QN
  E 0 90 21 50     ; delta=0, note-on, pitch=0x21 (33=A1), velocity=0x50 (80)
  E 1440 80 21 00   ; delta=1440, note-off, pitch=0x21
  E 120 B0 0B 32    ; delta=120, CC 11 (Expression), value=0x32 (50)
  ...
>
```

## Dependencies

- `src/core/schema.py` — `SongDefinition`, `TrackDef`, `ClipDef`, `PluginDef`
- `rpp` library — `Element`, `dumps` for `.rpp` XML-like serialization
- `uuid`, `random` (stdlib) — GUID generation
- `subprocess` (for `render.py`) — REAPER CLI execution

## Known Gotchas

1. **VST3 display names MUST match exactly**: The display_name in the registry must match what `TrackFX_AddByName()` expects in REAPER. A typo in the display_name field causes silent plugin load failure.

2. **Plugin builtin flag skips .rpp writing**: When `PluginDef.builtin == True`, the plugin is NOT written to the `.rpp` file. It's deferred to ReaScript. If the ReaScript doesn't run, the plugin won't be loaded.

3. **GUID format matters**: VST2 uses `<GUID>` with angle brackets in the `.rpp`. VST3 uses `{GUID}` with curly braces. Using the wrong delimiter format causes REAPER to reject the plugin entry.

4. **REAPER version string is hardcoded**: `_build_element()` hardcodes `"7.65/win64"` as the REAPER version. Different REAPER versions may behave differently.

5. **VOLPAN format**: REAPER expects volume as a linear value 0.0–1.0, not dB. Pan is -1.0 to 1.0. Values outside these ranges produce unexpected behavior.

6. **AUXRECV indices**: Send indices reference return track positions. Reverb is at index `len(content_tracks)`, Delay at `len(content_tracks) + 1`. Adding content tracks before wiring sends shifts these indices.

7. **Preset data is raw base64**: Plugin preset data is stored as base64-encoded binary chunks extracted from ground-truth `.rpp` files. They are NOT parameter lists — they're full plugin state dumps. Modifying presets requires re-extracting from REAPER.

8. **Headless render requires REAPER installed**: `render_project()` calls `reaper.exe` as a subprocess. REAPER must be installed at the expected path for rendering to work.
