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