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