- section-energy: track activity matrix + volume/velocity multipliers per section - smart-chords: ChordEngine with voice leading, inversions, 4 emotion modes - hook-melody: melody engine with hook/stabs/smooth styles, call-and-response - mix-calibration: Calibrator module (LUFS volumes, HPF/LPF, stereo, sends, master) - transitions-fx: FX track with risers/impacts/sweeps at section boundaries - sidechain: MIDI CC11 bass ducking on kick hits via DrumLoopAnalyzer - presets-pack: role-aware plugin presets (Serum/Decapitator/Omnisphere per role) Full SDD pipeline (propose→spec→design→tasks→apply→verify) for all 7 changes. 302/302 tests passing.
102 lines
4.9 KiB
Markdown
102 lines
4.9 KiB
Markdown
# 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.
|