Files
reaper-control/.sdd/changes/mix-calibration/design.md
renato97 014e636889 feat: professional reggaeton production engine — 7 SDD changes, 302 tests
- 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.
2026-05-03 23:54:29 -03:00

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.