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).
175 lines
6.4 KiB
Markdown
175 lines
6.4 KiB
Markdown
# 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.
|