# 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.