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:
174
docs/modules/calibrator.md
Normal file
174
docs/modules/calibrator.md
Normal 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.0–1.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.0–1.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
177
docs/modules/composer.md
Normal 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) | i7–VI7–III7–VII7 |
|
||||
| dark | (0,m7), (5,m7), (10,7), (3,7) | i7–iv7–VII7–III7 |
|
||||
| club | (0,m7), (8,7), (10,7), (7,7) | i7–VI7–VII7–V7 |
|
||||
| classic | (0,m7), (10,7), (8,7), (7,7) | i7–VII7–VI7–V7 |
|
||||
|
||||
### 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 2–8.
|
||||
- **`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 2–8 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.
|
||||
230
docs/modules/reaper-builder.md
Normal file
230
docs/modules/reaper-builder.md
Normal 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.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.
|
||||
205
docs/modules/reaper-scripting.md
Normal file
205
docs/modules/reaper-scripting.md
Normal 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.
|
||||
Reference in New Issue
Block a user