# Design: Automated Mix Calibration

## Technical Approach

Add a calibrator module as a post-processing step between `compose.main()` and `RPPBuilder.build()`. The calibrator mutates a `SongDefinition` in-place: sets role-based volumes/pans/sends, prepends ReaEQ plugins with HPF/LPF params, and swaps the master chain to Ozone 12. The `--no-calibrate` flag skips this entirely, preserving existing behavior.

## Architecture Decisions

| Decision | Choice | Rejected | Rationale |
|----------|--------|----------|-----------|
| Calibrator placement | Separate `src/calibrator/` module | Inline in compose.py | compose.py is 612 lines; calibration is a separate concern (mixing vs composition); follows existing module pattern (selector/, builder/) |
| ReaEQ injection | Prepended to `track.plugins` list as `PluginDef` with params dict | Separate data structure | `_build_plugin()` already handles PluginDef in plugin chains; zero new serialization format |
| ReaEQ param serialization | Populate `PluginDef.params` → `_build_plugin()` reads and fills VST param slots | New element builder | Reuses existing `_build_plugin` codepath; built-in VST2 plugins already have `param_slots = ["0"]*19` pattern (line 1785) |
| Master chain fallback | Try Ozone 12 first; fall back to Pro-Q_3/Pro-C_2/Pro-L_2 if missing from PLUGIN_REGISTRY | Raise error / skip | Graceful degradation on machines without iZotope plugins |
| Skip flag storage | `SongMeta.calibrate: bool` (optional, default True) | Global config / env var | Per-song granularity; schema already supports optional fields; zero impact on serialization |

## Data Flow

```
compose.main()
    │
    ├── build_*_track() → SongDefinition
    │
    ├── if not no_calibrate:
    │      Calibrator.apply(song)
    │        ├── _calibrate_volumes()     ← VOLUME_PRESETS
    │        ├── _calibrate_eq()          ← EQ_PRESETS → ReaEQ PluginDef.params
    │        ├── _calibrate_pans()        ← PAN_PRESETS
    │        ├── _calibrate_sends()       ← SEND_PRESETS
    │        └── _swap_master_chain()     ← Ozone 12 fallback to Pro-Q_3/Pro-C_2/Pro-L_2
    │
    └── RPPBuilder(song).write()
          └── _build_plugin(PluginDef)
                └── if built-in (ReaEQ) + params: fill param_slots[] from PluginDef.params
```

## File Changes

| File | Action | Description |
|------|--------|-------------|
| `src/calibrator/__init__.py` | Create | `Calibrator` class with `apply(song: SongDefinition) -> SongDefinition` |
| `src/calibrator/presets.py` | Create | `VOLUME_PRESETS`, `EQ_PRESETS`, `PAN_PRESETS`, `SEND_PRESETS` dicts keyed by role |
| `src/reaper_builder/__init__.py` | Modify | `_build_plugin()` — read `PluginDef.params` for built-in plugins (ReaEQ) and populate `param_slots` |
| `scripts/compose.py` | Modify | Import Calibrator; call `calibrator.apply(song)` after track construction; add `--no-calibrate` arg |
| `src/core/schema.py` | Modify | Add `calibrate: bool = True` to `SongMeta` |

## ReaEQ Param Serialization Detail

Current code (line 1785): `param_slots = ["0"] * 19` — always zeros.

After change: if `plugin.params` is non-empty and the plugin is a built-in VST2, read param index → value from the dict:
```python
param_slots = ["0"] * 19
if plugin.params:
    for idx, val in plugin.params.items():
        if 0 <= idx < 19:
            param_slots[idx] = str(val)
```

ReaEQ band 0 params (what we set):
- Slot 0: band enabled (1 = on)
- Slot 1: filter type (0 = LPF, 1 = HPF)
- Slot 2: frequency (Hz, e.g. 200.0)
- Slots 3-7: gain, Q, etc. (default 0)

## Interfaces

```python
# src/calibrator/__init__.py
class Calibrator:
    """Post-processing mix calibrator for SongDefinition."""

    @staticmethod
    def apply(song: SongDefinition) -> SongDefinition:
        """Apply role-based volume, EQ, pan, sends, and master chain calibration.

        Mutates song in-place and returns it.
        Skips tracks named 'Reverb' or 'Delay' (return tracks).
        """
        ...

    @staticmethod
    def _resolve_role(track_name: str) -> str | None:
        """Map track name to role key, or None."""
        ...
```

## Testing Strategy

| Layer | What | Approach |
|-------|------|----------|
| Unit | `_resolve_role()` mapping | All 7 track names → correct roles; unknown → None |
| Unit | `Calibrator.apply()` on fixture song | Assert volumes/pans/sends match presets; assert ReaEQ in plugins[0]; assert master_plugins swapped |
| Unit | `--no-calibrate` behavior | Assert `Calibrator.apply()` not called; master_plugins unchanged |
| Unit | Ozone fallback | Mock PLUGIN_REGISTRY without Ozone entries; assert fallback to Pro-Q_3/Pro-C_2/Pro-L_2 |
| Unit | ReaEQ param serialization | Build PluginDef with params={0:1, 1:1, 2:200.0}; assert output VST element has correct param slots |
| Regression | Existing 110 tests | All pass — calibration is additive |

## Open Questions

None.
