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.
This commit is contained in:
101
.sdd/changes/mix-calibration/design.md
Normal file
101
.sdd/changes/mix-calibration/design.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 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.
|
||||
87
.sdd/changes/mix-calibration/proposal.md
Normal file
87
.sdd/changes/mix-calibration/proposal.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Proposal: Automated Mix Calibration
|
||||
|
||||
## Intent
|
||||
|
||||
All track volumes, pans, and sends are hardcoded constants. No frequency balancing. Master chain uses Pro-Q_3/Pro-C_2/Pro-L_2 with DEFAULT presets. Result: flat, amateur sound with bass-drum masking and no stereo width.
|
||||
|
||||
Add a post-processing calibrator that sets role-based LUFS volumes, HPF/LPF EQ, stereo panning, calibrated sends, and a proper mastering chain.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- `src/calibrator/` module — calibrates a `SongDefinition` with role-aware mix settings
|
||||
- LUFS-targeted volumes per role (kick -8 → drumloop 0.85, bass -10 → 0.72, lead -12 → 0.78, etc.)
|
||||
- HPF/LPF via ReaEQ plugins prepended to each track (HPF on non-bass, LPF on bass)
|
||||
- Stereo width management: bass/kick mono, lead wide (±0.3), chords wider (±0.5), clap off-center
|
||||
- Calibrated send levels: lead 25% verb / 15% delay, chords 30% / 10%, pad 40% / 20%, drums 10% / 0%
|
||||
- Master chain swap: Pro-Q_3 → Ozone 12 Equalizer, Pro-C_2 → Ozone 12 Dynamics, Pro-L_2 → Ozone 12 Maximizer
|
||||
- `--no-calibrate` flag on compose.py to skip calibration
|
||||
|
||||
### Out of Scope
|
||||
- True LUFS measurement (requires REAPER rendering — Phase 2 via ReaScript)
|
||||
- ReaEQ parameter automation (parametric curves, dynamic EQ)
|
||||
- Reference-track matching
|
||||
- Multi-genre calibration profiles (reggaeton only for now)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `mix-calibration`: Role-based volume/pan/send/EQ calibration applied as post-processing step on `SongDefinition`
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None — existing compose pipeline is unchanged; calibration is additive -->
|
||||
|
||||
None
|
||||
|
||||
## Approach
|
||||
|
||||
**Separate calibrator module** (`src/calibrator/`), NOT inline in compose.py. Rationale:
|
||||
- compose.py is 612 lines — adding 200+ calibration lines would bloat it
|
||||
- Calibration is a separate concern (mixing vs. composition)
|
||||
- Independently testable, skippable via `--no-calibrate`
|
||||
- Follows existing module pattern (selector/, builder/, validator/)
|
||||
|
||||
**Data flow**: `compose.main()` → `SongDefinition` → `Calibrator.apply(song)` → calibrated `SongDefinition` → `RPPBuilder.build()`
|
||||
|
||||
**HPF/LPF strategy**: Add ReaEQ plugin to each track's plugin list. Extend `_build_plugin()` to serialize `PluginDef.params` into VST parameter slots (currently ignored). ReaEQ uses 19 fixed param slots; we populate band 0 (type=1 HPF or type=0 LPF) with frequency values.
|
||||
|
||||
**Master chain**: Replace `master_plugins=["Pro-Q_3","Pro-C_2","Pro-L_2"]` with `["Ozone_12_Equalizer","Ozone_12_Dynamics","Ozone_12_Maximizer"]` using default presets already in registry.
|
||||
|
||||
## Affected Areas
|
||||
|
||||
| Area | Impact | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/calibrator/__init__.py` | New | `Calibrator` class with `apply(song)` method |
|
||||
| `src/calibrator/presets.py` | New | Calibration presets (LUFS targets, HPF/LPF freqs, pans, sends) |
|
||||
| `src/reaper_builder/__init__.py` | Modified | `_build_plugin()` — serialize `PluginDef.params` to VST slots |
|
||||
| `scripts/compose.py` | Modified | Import Calibrator, call after track build, add `--no-calibrate` flag |
|
||||
| `tests/test_calibrator.py` | New | Unit tests for calibrator output |
|
||||
| `src/core/schema.py` | Modified | Add `calibrate: bool` flag to `SongMeta` (optional) |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|------|------------|------------|
|
||||
| ReaEQ param serialization breaks existing .rpp | Low | Feature-gated: only when `PluginDef.params` is non-empty; zero backcompat impact |
|
||||
| Ozone 12 plugins missing on some machines | Med | Fallback to Pro-Q_3/Pro-C_2/Pro-L_2 if Ozone registry lookup fails |
|
||||
| Too-aggressive HPF cuts thin out full sections | Low | Conservative cutoffs: HPF 60Hz for drums, 200Hz for lead/chords; tunable via presets |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
1. Revert compose.py: remove `--no-calibrate` flag, remove calibrator import
|
||||
2. Revert builder: remove params serialization in `_build_plugin()`
|
||||
3. Delete `src/calibrator/`
|
||||
4. Restore original `VOLUME_LEVELS`, `SEND_LEVELS`, `MASTER_VOLUME`, `master_plugins` constants
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `PLUGIN_REGISTRY` entries for `ReaEQ`, `Ozone_12_Equalizer`, `Ozone_12_Dynamics`, `Ozone_12_Maximizer` (all exist)
|
||||
- No new Python dependencies required
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `Calibrator.apply(song)` returns a `SongDefinition` with volume/pan/send values matching role-based presets
|
||||
- [ ] Each non-return track has at least one ReaEQ plugin with HPF or LPF params set
|
||||
- [ ] `--no-calibrate` flag preserves existing behavior (no calibration applied)
|
||||
- [ ] Generated .rpp with calibration produces audibly cleaner mix (verified by ear)
|
||||
- [ ] All 110 existing tests still pass (calibration is additive, not breaking)
|
||||
106
.sdd/changes/mix-calibration/spec.md
Normal file
106
.sdd/changes/mix-calibration/spec.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# mix-calibration Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Post-processing calibrator that applies role-aware volume, EQ, stereo width, sends, and mastering chain to a `SongDefinition` before `.rpp` generation.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Calibrator Post-Processing
|
||||
|
||||
The system MUST provide a `Calibrator.apply(song: SongDefinition) -> SongDefinition` method that mutates and returns the song with calibrated mix settings. Calibration MUST run as a distinct step between track construction and `RPPBuilder.build()`.
|
||||
|
||||
#### Scenario: Happy path — full calibration
|
||||
|
||||
- GIVEN a complete `SongDefinition` with 7 tracks (Drumloop, Perc, 808 Bass, Chords, Lead, Clap, Pad) and 2 return tracks
|
||||
- WHEN `Calibrator.apply(song)` is called
|
||||
- THEN `song.tracks[].volume` matches role-based LUFS targets
|
||||
- AND each non-return track has a ReaEQ plugin prepended to its `plugins` list
|
||||
- AND `song.tracks[].pan` follows stereo-width rules
|
||||
- AND `song.tracks[].send_level` contains calibrated reverb/delay values
|
||||
- AND `song.master_plugins` contains Ozone 12 Equalizer, Dynamics, Maximizer
|
||||
|
||||
### Requirement: Role-Based Volumes
|
||||
|
||||
The system SHALL set track volumes from a preset table keyed by track role (name → role mapping). Volumes MUST be in the REAPER-compatible 0.0–1.0 range.
|
||||
|
||||
| Role | Volume | Target |
|
||||
|------|--------|--------|
|
||||
| drumloop | 0.85 | kick prominence |
|
||||
| bass | 0.72 | sub-presence |
|
||||
| chords | 0.78 | harmonic support |
|
||||
| lead | 0.78 | melody clarity |
|
||||
| clap | 0.75 | transient punch |
|
||||
| pad | 0.68 | ambient depth |
|
||||
| perc | 0.72 | groove feel |
|
||||
|
||||
#### Scenario: Unknown track role
|
||||
|
||||
- GIVEN a track with name not matching any preset role
|
||||
- WHEN calibrated
|
||||
- THEN the track's volume and pan remain unchanged (preserved as-is)
|
||||
|
||||
### Requirement: HPF/LPF EQ per Role
|
||||
|
||||
The system SHALL prepend a ReaEQ `PluginDef` to each non-return track's `plugins` list with appropriate HPF or LPF parameters. Bass tracks (808 Bass) SHALL receive LPF. All other tracks SHALL receive HPF.
|
||||
|
||||
#### Scenario: HPF on lead/chords/pad tracks
|
||||
|
||||
- GIVEN a track named "Chords", "Lead", "Pad", "Clap", "Perc", or "Drumloop"
|
||||
- WHEN calibrated
|
||||
- THEN a ReaEQ plugin is inserted at `plugins[0]` with param `0=1` (band enabled), `1=1` (HPF type), `2=200.0` (frequency for melodic) or `2=60.0` (drums)
|
||||
|
||||
#### Scenario: LPF on bass track
|
||||
|
||||
- GIVEN a track named "808 Bass"
|
||||
- WHEN calibrated
|
||||
- THEN a ReaEQ plugin is inserted at `plugins[0]` with param `0=1`, `1=0` (LPF type), `2=300.0` (frequency)
|
||||
|
||||
#### Scenario: Return tracks excluded
|
||||
|
||||
- GIVEN tracks named "Reverb" or "Delay"
|
||||
- WHEN calibrated
|
||||
- THEN no ReaEQ plugin is added (return tracks are skipped)
|
||||
|
||||
### Requirement: Stereo Width per Role
|
||||
|
||||
The system SHALL set track pan values to role-specific defaults.
|
||||
|
||||
| Role | Pan | Rationale |
|
||||
|------|-----|-----------|
|
||||
| drumloop | 0.0 | mono center |
|
||||
| bass | 0.0 | mono sub |
|
||||
| chords | +0.5 | wide right |
|
||||
| lead | +0.3 | right-leaning |
|
||||
| clap | -0.15 | off-center left |
|
||||
| pad | -0.5 | wide left |
|
||||
| perc | +0.12 | slight right |
|
||||
|
||||
### Requirement: Send Calibration
|
||||
|
||||
The system SHALL set `send_level` dict entries for reverb (index=return_track_count) and delay (index=return_track_count+1) on each non-return track.
|
||||
|
||||
| Role | Reverb | Delay |
|
||||
|------|--------|-------|
|
||||
| drumloop | 0.10 | 0.00 |
|
||||
| bass | 0.05 | 0.02 |
|
||||
| chords | 0.30 | 0.10 |
|
||||
| lead | 0.25 | 0.15 |
|
||||
| clap | 0.10 | 0.00 |
|
||||
| pad | 0.40 | 0.20 |
|
||||
| perc | 0.10 | 0.00 |
|
||||
|
||||
### Requirement: Master Chain Upgrade
|
||||
|
||||
The system SHALL replace `master_plugins` with `["Ozone_12_Equalizer","Ozone_12_Dynamics","Ozone_12_Maximizer"]`. If registry lookup for any Ozone plugin fails, the system MUST fall back to `["Pro-Q_3","Pro-C_2","Pro-L_2"]`.
|
||||
|
||||
### Requirement: Calibration Toggle
|
||||
|
||||
The system SHALL support a `--no-calibrate` CLI flag. When passed, `Calibrator.apply()` MUST NOT be called. When omitted (default), calibration MUST run. `SongMeta` MAY include an optional `calibrate: bool` field defaulting to `True`.
|
||||
|
||||
#### Scenario: --no-calibrate preserves existing behavior
|
||||
|
||||
- GIVEN `compose.py --no-calibrate -o out.rpp`
|
||||
- WHEN the song is built
|
||||
- THEN `Calibrator.apply()` is never invoked
|
||||
- AND the generated `.rpp` matches the pre-calibration baseline
|
||||
30
.sdd/changes/mix-calibration/tasks.md
Normal file
30
.sdd/changes/mix-calibration/tasks.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Tasks: Automated Mix Calibration
|
||||
|
||||
## Phase 1: Foundation
|
||||
|
||||
- [x] 1.1 Create `src/calibrator/presets.py` — `VOLUME_PRESETS`, `EQ_PRESETS` (HPF/LPF freq per role), `PAN_PRESETS`, `SEND_PRESETS` dicts
|
||||
- [x] 1.2 Add `calibrate: bool = True` optional field to `SongMeta` in `src/core/schema.py`
|
||||
- [x] 1.3 Create `src/calibrator/__init__.py` with `Calibrator` class stub and `_resolve_role()` method (name → role key)
|
||||
|
||||
## Phase 2: Core Calibrator
|
||||
|
||||
- [x] 2.1 Implement `_calibrate_volumes(song)` — set track.volume from VOLUME_PRESETS by role; skip unknown roles
|
||||
- [x] 2.2 Implement `_calibrate_pans(song)` — set track.pan from PAN_PRESETS by role
|
||||
- [x] 2.3 Implement `_calibrate_sends(song)` — set track.send_level for reverb/delay return indices from SEND_PRESETS
|
||||
- [x] 2.4 Implement `_calibrate_eq(song)` — prepend ReaEQ PluginDef with params dict (HPF/LPF) to track.plugins; skip return tracks
|
||||
- [x] 2.5 Implement `_swap_master_chain(song)` — replace master_plugins with Ozone 12 triplet; fall back to Pro-Q_3/Pro-C_2/Pro-L_2 if Ozone not in PLUGIN_REGISTRY
|
||||
- [x] 2.6 Implement `Calibrator.apply(song)` orchestrating all _calibrate_* methods, returning the mutated song
|
||||
|
||||
## Phase 3: Builder & Integration
|
||||
|
||||
- [x] 3.1 Modify `_build_plugin()` in `src/reaper_builder/__init__.py` — read `PluginDef.params` for built-in VST2 plugins and populate param_slots
|
||||
- [x] 3.2 Wire calibrator into `scripts/compose.py` — import Calibrator, call `calibrator.apply(song)` after track construction, before RPPBuilder
|
||||
- [x] 3.3 Add `--no-calibrate` flag to compose.py argparse; when set, skip calibrator call and SongMeta.calibrate=False
|
||||
|
||||
## Phase 4: Testing
|
||||
|
||||
- [x] 4.1 Create `tests/test_calibrator.py` — unit tests for `_resolve_role()`, each `_calibrate_*()` method against fixture SongDefinition
|
||||
- [x] 4.2 Test `Calibrator.apply()` end-to-end — volumes, pans, sends, ReaEQ presence, master plugins all match presets
|
||||
- [x] 4.3 Test `--no-calibrate` flag — calibrator not called, master_plugins unchanged
|
||||
- [x] 4.4 Test Ozone fallback — mock empty Ozone registry entries, verify Pro-Q_3/Pro-C_2/Pro-L_2 used
|
||||
- [x] 4.5 Run existing test suite — verify all 110+ tests still pass
|
||||
Reference in New Issue
Block a user