docs: LLM-ready documentation suite — LLM_CONTEXT.md, module deep-dives, JSON schemas, CLI reference

Complete documentation system for LLM consumption: primary LLM_CONTEXT.md
(27KB system overview), 4 module deep-dives (composer, reaper-builder,
reaper-scripting, calibrator), 2 JSON Schema draft-07 contracts, CLI
reference, and README correction (FL Studio -> REAPER identity).
This commit is contained in:
renato97
2026-05-04 10:30:24 -03:00
parent b08dcccca2
commit 7bcd8052a9
13 changed files with 2402 additions and 29 deletions

174
docs/modules/calibrator.md Normal file
View File

@@ -0,0 +1,174 @@
# Calibrator Module
> Parent doc: [LLM_CONTEXT.md](../LLM_CONTEXT.md)
## Purpose
Post-processing mix calibration. Applies role-based volume, EQ, pan, sends, and master chain configuration to a `SongDefinition` after track construction and before `.rpp` generation.
**Location**: `src/calibrator/`
## Public API
### Calibrator (`__init__.py`)
```python
class Calibrator:
@staticmethod
def apply(song: SongDefinition) -> SongDefinition
```
- **`apply()`**: Applies role-based volume, pan, sends, and master chain calibration. Mutates `song` in-place and returns it. Skips tracks named `"Reverb"` or `"Delay"` (return tracks).
- **Pipeline**: `_calibrate_volumes()``_calibrate_pans()``_calibrate_sends()``_swap_master_chain()`
### Role Resolution (`__init__.py`)
```python
@staticmethod
def _resolve_role(track_name: str) -> str | None
```
Maps track name to role key using substring matching:
| Track Name Pattern | Role | Description |
|-------------------|------|-------------|
| `"Drumloop"`, `"drum*"` | `"drumloop"` | Drum loop |
| `"808 Bass"`, `"*bass*"` | `"bass"` | Bass |
| `"Chords"`, `"*chord*"` | `"chords"` | Chords/harmony |
| `"Lead"`, `"*lead*"`, `"*synth*"`, `"*melody*"` | `"lead"` | Lead melody |
| `"Clap"`, `"*snare*"`, `"*clap*"` | `"clap"` | Clap/snare |
| `"Pad"`, `"*pad*"`, `"*atmos*"`, `"*ambient*"` | `"pad"` | Pad/atmosphere |
| `"Perc"`, `"*perc*"` | `"perc"` | Percussion |
| `"Reverb"`, `"Delay"` | `None` | Return tracks (skipped) |
| Any other name | `None` | Unknown role (skipped) |
### Presets (`presets.py`)
```python
VOLUME_PRESETS: dict[str, float]
PAN_PRESETS: dict[str, float]
EQ_PRESETS: dict[str, dict[int, float]]
SEND_PRESETS: dict[str, tuple[float, float]]
```
#### Volume Presets (0.01.0 REAPER volume)
| Role | Volume |
|------|--------|
| `drumloop` | 0.85 |
| `bass` | 0.82 |
| `chords` | 0.75 |
| `lead` | 0.80 |
| `clap` | 0.78 |
| `pad` | 0.70 |
| `perc` | 0.80 |
#### Pan Presets (-1.0 to 1.0)
| Role | Pan |
|------|-----|
| `drumloop` | 0.0 (center) |
| `bass` | 0.0 (center) |
| `chords` | 0.5 (right) |
| `lead` | 0.3 (slightly right) |
| `clap` | -0.15 (slightly left) |
| `pad` | -0.5 (left) |
| `perc` | 0.12 (slightly right) |
#### EQ Presets (ReaEQ VST2 param slots)
| Role | Band Enabled | Filter Type | Frequency |
|------|-------------|-------------|-----------|
| `drumloop` | 1 (on) | 1 (HPF) | 60 Hz |
| `bass` | 1 (on) | 0 (LPF) | 300 Hz |
| `chords` | 1 (on) | 1 (HPF) | 200 Hz |
| `lead` | 1 (on) | 1 (HPF) | 200 Hz |
| `clap` | 1 (on) | 1 (HPF) | 200 Hz |
| `pad` | 1 (on) | 1 (HPF) | 100 Hz |
| `perc` | 1 (on) | 1 (HPF) | 200 Hz |
EQ presets use ReaEQ's VST2 parameter layout (slot 0 = enabled, slot 1 = filter type, slot 2 = frequency). These are passed to `PluginDef.params` and applied by the ReaScript's `configure_fx_params` action.
#### Send Presets (reverb, delay) — 0.01.0
| Role | Reverb Send | Delay Send |
|------|------------|------------|
| `drumloop` | 0.10 | 0.00 |
| `bass` | 0.05 | 0.00 |
| `chords` | 0.40 | 0.10 |
| `lead` | 0.30 | 0.15 |
| `clap` | 0.10 | 0.00 |
| `pad` | 0.50 | 0.20 |
| `perc` | 0.10 | 0.00 |
### Master Chain Upgrade (`__init__.py`)
```python
@staticmethod
def _swap_master_chain(song: SongDefinition) -> None
```
Replaces `master_plugins` with a processing chain:
1. **Ozone 12 triplet**: `Ozone_12_Equalizer``Ozone_12_Dynamics``Ozone_12_Maximizer`
2. **Fallback** (if any Ozone plugin missing from `PLUGIN_REGISTRY`): `Pro-Q_3``Pro-C_2``Pro-L_2`
Check is performed at calibration time. Missing plugins silently fall back.
## Data Flow
```
SongDefinition (after track construction)
Calibrator.apply(song)
├── _calibrate_volumes(song)
│ For each track:
│ role = _resolve_role(track.name)
│ if role in VOLUME_PRESETS: track.volume = VOLUME_PRESETS[role]
├── _calibrate_pans(song)
│ For each track:
│ role = _resolve_role(track.name)
│ if role in PAN_PRESETS: track.pan = PAN_PRESETS[role]
├── _calibrate_sends(song)
│ Count content tracks and return tracks
│ reverb_idx = num_content; delay_idx = num_content + 1
│ For each track:
│ role = _resolve_role(track.name)
│ if role in SEND_PRESETS:
│ track.send_level[reverb_idx] = SEND_PRESETS[role][0]
│ track.send_level[delay_idx] = SEND_PRESETS[role][1]
└── _swap_master_chain(song)
Check REGISTRY for Ozone triplet availability
song.master_plugins = ozone_triplet or fallback_triplet
SongDefinition (calibrated, mutated in-place)
```
## Dependencies
- `src/core/schema.py``SongDefinition`, `TrackDef`, `PluginDef`
- `src/calibrator/presets.py``VOLUME_PRESETS`, `PAN_PRESETS`, `EQ_PRESETS`, `SEND_PRESETS`
- `src/reaper_builder` (lazy import in `_swap_master_chain`) — `PLUGIN_REGISTRY`
## Known Gotchas
1. **Calibration mutates in-place**: `apply()` modifies the `SongDefinition` directly. If you need the uncalibrated version, keep a copy before calling.
2. **Role resolution is substring-based**: `"drum"` substring matches `"drumloop"` role. `"bass"` substring matches `"bass"`. Track names like `"Bassline"` or `"Drum Kit"` will resolve correctly. But `"SubBass"` also matches because `"bass"` is a substring.
3. **Send indices depend on track ordering**: `_calibrate_sends()` counts content tracks (non-return) to compute reverb/delay indices. If tracks are reordered after calibration, send indices will be wrong.
4. **EQ presets require ReaScript execution**: `EQ_PRESETS` values are not applied by `Calibrator` directly. They must be embedded in `PluginDef.params` for ReaEQ instances and applied by the ReaScript's `configure_fx_params` action.
5. **Master chain is replaced, not extended**: `_swap_master_chain()` replaces the entire `master_plugins` list. Any pre-existing master plugins are lost.
6. **Ozone fallback is silent**: If Ozone plugins aren't in the registry, the FabFilter fallback is used with no warning.
7. **Return tracks identified by exact name**: Only tracks named `"Reverb"` or `"Delay"` (case-insensitive) are treated as return tracks. `"Reverb Return"` or `"Verb"` would NOT be recognized.
8. **Calibration disabled via SongMeta.calibrate**: If `song.meta.calibrate` is `False`, the pipeline (in `scripts/compose.py`) skips `Calibrator.apply()`. But a caller could still invoke `apply()` directly.

177
docs/modules/composer.md Normal file
View File

@@ -0,0 +1,177 @@
# Composer Module
> Parent doc: [LLM_CONTEXT.md](../LLM_CONTEXT.md)
## Purpose
The composer module generates musical content for reggaetón tracks: chord progressions, melodies, rhythm patterns, and drum analysis. All generators are deterministic given a seed.
**Location**: `src/composer/`
## Public API
### ChordEngine (`chords.py`)
```python
class ChordEngine:
def __init__(self, key: str, seed: int = 42) -> None
def progression(
self,
bars: int,
emotion: str = "classic",
beats_per_chord: int = 4,
inversion: str = "root",
) -> list[list[int]]
```
- **Input**: Key string (`"Am"`, `"Dm"`), number of bars, emotion name, beats per chord, inversion preference
- **Output**: List of voicings — each voicing is `list[int]` of MIDI notes
- **Emotions**: `"romantic"`, `"dark"`, `"club"`, `"classic"` (unknown falls back to classic)
- **Deterministic**: Same `(key, seed)` → identical output. RNG re-seeded per call.
- **Voice leading**: Greedy min-score path through root, first, and second inversion candidates. Uses `random.Random(seed)` as micro-tiebreaker.
**Emotion progressions** (all 4-chord loops, semitone offsets from tonic):
| Emotion | Offsets | Labels |
|---------|---------|--------|
| romantic | (0,m7), (8,7), (3,7), (10,7) | i7VI7III7VII7 |
| dark | (0,m7), (5,m7), (10,7), (3,7) | i7iv7VII7III7 |
| club | (0,m7), (8,7), (10,7), (7,7) | i7VI7VII7V7 |
| classic | (0,m7), (10,7), (8,7), (7,7) | i7VII7VI7V7 |
### Melody Engine (`melody_engine.py`)
```python
def build_motif(
key_root: str,
key_minor: bool,
style: str,
bars: int = 4,
seed: int = 42,
) -> list[MidiNote]
def apply_variation(
motif: list[MidiNote],
shift_beats: float = 0.0,
transpose_semitones: int = 0,
) -> list[MidiNote]
def build_call_response(
motif: list[MidiNote],
bars: int = 8,
key_root: str = "A",
key_minor: bool = True,
seed: int = 42,
) -> list[MidiNote]
```
- **`build_motif()`**: Generate a repeating motif. Styles: `"hook"` (arch contour, chord-tone emphasis), `"stabs"` (short hits on dembow grid), `"smooth"` (stepwise scalar motion). Raises `ValueError` for invalid style. Bars clamped to 28.
- **`apply_variation()`**: Apply beat shift and/or semitone transpose to a motif. Returns new list; original unchanged.
- **`build_call_response()`**: Build call-and-response structure. First half: motif repeats, last note forced to V or VII (tension). Second half: motif repeats, last note forced to i (resolution).
### Pattern Tables (`patterns.py`)
Weight tables extracted from real reggaetón track analysis (99.4 BPM and 132.5 BPM tracks). Contains 16th-note position frequency weights for kick, snare, and hihat. Used by rhythm generators to produce idiomatically accurate patterns.
### Rhythm Generators (`rhythm.py`)
Pure functions returning note dicts per MIDI channel:
```python
def generate_kick(bars: int, style: str, ...) -> list[dict]
def generate_snare(bars: int, ...) -> list[dict]
def generate_hihat(bars: int, ...) -> list[dict]
```
Dembow styles: `"classico"` (kicks on 1, 3, 4&), `"perreo"` (adds kick on 2&), `"trapico"` (half-time kicks on 1, 3). MIDI channels: CH_K=11, CH_S=12, CH_R=13, CH_H=15, CH_CL=16.
### RPP Templates (`templates.py`)
```python
def extract_template(rpp_path: str) -> TemplateProject
def generate_rpp(template: TemplateProject, output_path: str) -> None
```
Extracts track/FX chain structures from professionally-built `.rpp` files, fixes GUIDs using `reaper-vstplugins64.ini`, and regenerates working `.rpp` files with correct plugin references.
### Legacy Generators (`__init__.py`)
```python
def generate_dembow(bars: int = 8, ppq: int = 96) -> list[dict]
def generate_bass_808(chord_progression: list[str], ...) -> list[dict]
def generate_piano_stabs(chord_progression: list[str], ...) -> list[dict]
def generate_lead_hook(chord_progression: list[str], ...) -> list[dict]
def generate_pad(chord_progression: list[str], ...) -> list[dict]
def generate_latin_perc(bars: int = 8) -> list[dict]
def compose_from_genre(genre_path: str | Path, ...) -> dict
```
These are the legacy pattern generators used by `compose_from_genre()`. They produce note dicts with keys `{"position", "length", "key", "velocity"}`. The modern pipeline (`scripts/compose.py`) uses `ChordEngine` and `melody_engine` instead.
Constants:
- `SCALE_INTERVALS`: major, minor, harmonic_minor, melodic_minor, dorian, phrygian
- `CHORD_TYPES`: maj [0,4,7], min [0,3,7], dim [0,3,6], aug [0,4,8], 7 [0,4,7,10], m7 [0,3,7,10], sus2 [0,2,7], sus4 [0,5,7]
### Drum Analyzer (`drum_analyzer.py`)
```python
class DrumLoopAnalyzer:
def __init__(self, audio_path: str) -> None
def analyze(self) -> Analysis
```
Analyzes audio files for transient detection. Used by `scripts/compose.py` to detect kick drum positions for CC11 sidechain ducking on the 808 bass track.
## Data Flow
```
CLI args (--key, --emotion, --inversion, --seed)
ChordEngine(key, seed)
│ .progression(bars, emotion, beats_per_chord, inversion)
list[list[int]] ← voicings (each is a list of MIDI notes)
compose.py builds Chord clips: MidiNote objects per bar
CLI args (--key, --seed)
build_motif(key_root, key_minor, "hook", bars, seed)
list[MidiNote]
build_call_response(motif, bars, key_root, key_minor, seed+1)
list[MidiNote] ← call-response melody for lead track
```
## Dependencies
- `src/core/schema.py``MidiNote`
- `src/composer/` internal:
- `chords.py` depends on `__init__.py` (`CHORD_TYPES`)
- `melody_engine.py` depends on `schema.py`
- `templates.py` depends on `src/reaper_builder` (`PLUGIN_REGISTRY`, `ALIAS_MAP`, `vst2_element`, `vst3_element`, `PLUGIN_PRESETS`)
- `random` (stdlib) — seeded RNG for determinism
## Known Gotchas
1. **ChordEngine._voice_leading is greedy, not optimal**: Picks the lowest-scoring candidate per step. There's no backtracking. Two seeds can produce noticeably different voicings because the greedy path depends on the RNG shuffle of candidates.
2. **Emotion fallback is silent**: Unknown emotion strings silently fall back to `"classic"`. No warning or error.
3. **Melody bars clamping**: `build_motif()` clamps bars to 28 silently. Requesting 16 bars returns exactly what 8 bars would return.
4. **Call-response hardcodes `_CHORD_PROGRESSION`**: `build_call_response()` uses its own i-VI-III-VII progression from `melody_engine.py` (duplicated from `compose.py`), NOT what was used by `ChordEngine`. If the chords and lead use different progressions, they will clash.
5. **Legacy generators use dict format, not MidiNote**: `generate_dembow()`, `generate_bass_808()`, etc. return `list[dict]` with string keys (`"position"`, `"length"`, `"key"`, `"velocity"`). The modern pipeline uses `list[MidiNote]` dataclass instances. These are NOT interchangeable.
6. **Drum analyzer timing**: `DrumLoopAnalyzer` converts seconds to beats using BPM, but the BPM is the project BPM, not necessarily the sample's BPM. If a drumloop is recorded at 90 BPM but the project is 99 BPM, kick positions will be misaligned.

View File

@@ -0,0 +1,230 @@
# 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.01.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.

View File

@@ -0,0 +1,205 @@
# 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.