- 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.
4.9 KiB
4.9 KiB
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:
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
# 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.