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:
82
.sdd/changes/sidechain/design.md
Normal file
82
.sdd/changes/sidechain/design.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Design: 808 Bass Sidechain Ducking
|
||||
|
||||
## Technical Approach
|
||||
|
||||
Extend `ClipDef` with `midi_cc: list[CCEvent]`, inject kick positions from `DrumLoopAnalyzer` into `build_bass_track()`, and modify `_build_midi_source()` to emit CC E-lines interleaved with notes. Pure MIDI — zero plugin or REAPER-specific features required.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| Decision | Choice | Rejected | Rationale |
|
||||
|----------|--------|----------|-----------|
|
||||
| CC representation | `dataclass CCEvent(controller, time, value)` | Dict/reuse MidiNote | Controller field orthogonal to pitch; typed dataclass catches errors at import time |
|
||||
| CC in _build_midi_source | Sort `notes+cc` by time, single pass | Separate CC loop after notes | Single sorted pass guarantees correct delta-encoding; avoids cursor reset bugs |
|
||||
| Kick cache lifetime | Module-level `dict[str, list[float]]` in compose.py | per-function lru_cache | Drumloop reused across sections; WAV path is natural stable key |
|
||||
| Duck shape constants | `_CC11_DIP=50, _CC11_HOLD=0.02, _CC11_RELEASE=0.18` | Configuration file | 3 constants — config file is overkill; easy to change in-code |
|
||||
| DrumLoopAnalyzer integration | Call `analyze()` once per unique WAV path | Per-section analysis | ~1s per analysis; caching avoids N×1s for N sections |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
drumloop WAV
|
||||
→ DrumLoopAnalyzer.analyze() → transient_positions("kick")
|
||||
→ filter confidence ≥ 0.6 → convert seconds→beats via bpm
|
||||
→ _get_kick_cache() returns list[float]
|
||||
→ build_bass_track(sections, offsets, key_root, key_minor, kick_cache)
|
||||
→ per section: filter kicks within [clip_start, clip_end] beats
|
||||
→ per kick in range: CCEvent(11, kick_t, 50), CCEvent(11, kick_t+0.02, 50), CCEvent(11, kick_t+0.18, 127)
|
||||
→ ClipDef(..., midi_cc=[...])
|
||||
→ RPPBuilder._build_midi_source()
|
||||
→ merge notes+cc, sort by time → emit E-lines delta-encoded
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/core/schema.py` | Modify | Add `CCEvent` dataclass; add `midi_cc: list[CCEvent] = field(default_factory=list)` to `ClipDef` |
|
||||
| `scripts/compose.py` | Modify | Add `_KICK_CONFIDENCE_THRESHOLD`, `_CC11_*` constants; add `_get_kick_cache()` function; modify `build_bass_track()` signature and CC generation; update `main()` to build kick cache |
|
||||
| `src/reaper_builder/__init__.py` | Modify | Merge `clip.midi_notes + clip.midi_cc` sorted by time in `_build_midi_source()`; emit `E delta B0 0B {value:02x}` for CC events |
|
||||
|
||||
## Interfaces / Contracts
|
||||
|
||||
```python
|
||||
# src/core/schema.py — new dataclass
|
||||
@dataclass
|
||||
class CCEvent:
|
||||
controller: int # 11 = Expression (CC11)
|
||||
time: float # beats from clip start
|
||||
value: int # 0–127
|
||||
|
||||
# ClipDef — new field
|
||||
midi_cc: list[CCEvent] = field(default_factory=list)
|
||||
|
||||
# compose.py — new function
|
||||
def _get_kick_cache(drumloop_paths: list[str], bpm: float) -> dict[str, list[float]]:
|
||||
"""Analyze drumloops, return {path: [kick_time_beats]}."""
|
||||
```
|
||||
|
||||
## E-line Encoding Detail
|
||||
|
||||
Current: `E {delta_ticks} {status} {data1} {data2}`
|
||||
Note on: `E 480 90 3c 50` (note 60, vel 80, delta=480 ticks)
|
||||
Note off: `E 960 80 3c 00`
|
||||
CC11: `E 0 B0 0B 32` (controller 11=B, CC message 0xB0, val 50=0x32)
|
||||
|
||||
Merging: sort `[(n.start, "n", note), (c.time, "c", cc), ...]` by time. CC events contribute zero to cursor (no duration — delta-only).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Layer | What | Approach |
|
||||
|-------|------|----------|
|
||||
| Unit | `CCEvent` dataclass | Round-trip serialization, default values |
|
||||
| Unit | `_build_midi_source` CC emission | Feed `ClipDef` with CC events, parse output for `B0 0B` lines |
|
||||
| Integration | `build_bass_track` with kick cache | Mock `DrumLoopAnalyzer`, verify `midi_cc` populated |
|
||||
| E2E | Full pipeline with real drumloop | Generate .rpp, grep for `B0 0B` in output, verify in REAPER |
|
||||
|
||||
## Migration / Rollout
|
||||
|
||||
No migration required. `midi_cc` defaults to empty list — all existing code paths unchanged. One-commit revert: remove `midi_cc` field, revert builder merge, delete `_get_kick_cache()`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
101
.sdd/changes/sidechain/proposal.md
Normal file
101
.sdd/changes/sidechain/proposal.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Proposal: 808 Bass Sidechain Ducking
|
||||
|
||||
## Intent
|
||||
|
||||
808 bass and kick drum overlap in low frequencies with zero separation. Professional reggaeton uses sidechain-style ducking — bass dips when kick hits — creating the "pumping" feel and preventing low-frequency mud. Currently `build_bass_track()` generates static-velocity MIDI notes with no awareness of the drumloop's kick pattern.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- Pre-analyze drumloop WAV files to extract kick transient positions via `DrumLoopAnalyzer`
|
||||
- Cache kick beat-positions per drumloop path (same file reused across sections)
|
||||
- Generate MIDI CC11 (Expression) events on bass clips at kick hit positions
|
||||
- Duck shape: instantaneous drop to CC11≈50, 80ms release ramp to CC11=127
|
||||
- `ClipDef` schema extended with `midi_cc: list[CCEvent]` field
|
||||
- `RPPBuilder._build_midi_source()` emits CC E-lines interleaved with Note events
|
||||
|
||||
### Out of Scope
|
||||
- Track-level volume automation envelopes (`VOLENV2`) — complex binary encoding, deferred
|
||||
- ReaComp-sidechain routing via ReaScript — Phase 2 enhancement only
|
||||
- DrumLoopAnalyzer integration at composition time (not pre-cached) — deferred to Phase 2
|
||||
- Ducking for non-bass tracks (chords, lead, pad)
|
||||
- User-configurable duck depth/shape — constants only
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `midi-cc-events`: MIDI CC event emission in `.rpp` source — CC11 Expression events interleaved with notes in E-line stream
|
||||
- `kick-detection-cache`: `DrumLoopAnalyzer` tied into composition pipeline; kick positions cached per drumloop WAV path
|
||||
|
||||
### Modified Capabilities
|
||||
- `bass-generation`: `build_bass_track()` accepts kick position data and generates per-note velocity ducking OR CC11 events synchronized to kick hits
|
||||
- `rpp-clip-encoding`: `_build_midi_source()` emits `E B0 0B xx` lines alongside Note On/Off
|
||||
|
||||
## Approach
|
||||
|
||||
**Principle**: MIDI CC11 (Expression) is the simplest `.rpp`-native sidechain. No REAPER-specific features, no binary envelope encoding, no ReaScript bridge. Pure MIDI standard — works with any synth (Serum 2 confirmed).
|
||||
|
||||
**Data flow**:
|
||||
```
|
||||
Drumloop WAV
|
||||
→ DrumLoopAnalyzer.analyze() → transient_positions("kick")
|
||||
→ beat-positions cache (dict[str, list[float]])
|
||||
→ build_bass_track(sections, offsets, key_root, key_minor, kick_cache={})
|
||||
→ generates CCEvent objects {controller=11, time, value}
|
||||
→ ClipDef.midi_cc = [...]
|
||||
→ RPPBuilder._build_midi_source() emits E-lines
|
||||
```
|
||||
|
||||
**CC11 ducking shape per kick hit** (all times in beats relative to clip start):
|
||||
| Offset from kick | CC11 Value | Description |
|
||||
|-----------------|------------|-------------|
|
||||
| kick_time | 50 | Instant dip (~-9dB) |
|
||||
| kick_time + 0.02| 50 | Hold through transient |
|
||||
| kick_time + 0.18| 127 | Release complete (80ms ≈ 0.16 beats) |
|
||||
|
||||
**Key decision — MIDI CC11 vs alternatives**:
|
||||
|
||||
| Option | Verdict | Why |
|
||||
|--------|---------|-----|
|
||||
| **A: MIDI CC11 (Expression)** | ✅ Chosen | `.rpp` MIDI source format supports `E B0 0B xx` lines. Serum 2, most synths respond. Trivial builder change. |
|
||||
| B: Track volume envelope (VOLENV2) | ❌ Rejected | Binary/chunk encoding in `.rpp` — fragile, hard to debug, no benefit over CC11 for this use case. |
|
||||
| C: ReaScript ReaComp sidechain | ⏸️ Deferred | Works only in Phase 2 with REAPER running. Use as future enhancement for non-MIDI audio basses. |
|
||||
|
||||
## Affected Areas
|
||||
|
||||
| Area | Impact | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/core/schema.py` | Modified | Add `CCEvent` dataclass (`controller`, `time`, `value`); add `midi_cc: list[CCEvent]` to `ClipDef` |
|
||||
| `scripts/compose.py` | Modified | Add `_get_kick_cache()`, pass to `build_bass_track()`, generate CC11 events in bass clips |
|
||||
| `src/reaper_builder/__init__.py` | Modified | `_build_midi_source()` interleaves CC events into E-line stream |
|
||||
| `src/composer/drum_analyzer.py` | Unchanged | Already exports `transient_positions("kick")` — zero changes needed |
|
||||
| `tests/test_compose_integration.py` | Modified | Verify CC events present in bass clips, correct CC11 values at kick positions |
|
||||
| `tests/test_reaper_builder.py` | Modified | Verify `_build_midi_source()` emits `B0 0B` E-lines |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|------|------------|------------|
|
||||
| Synth doesn't respond to CC11 | Low | Serum 2, Omnisphere, Diva all support it. Add `_CC11_VOLUME_MIN` constant for easy disable (set to 127 = no ducking). |
|
||||
| DrumloopAnalyzer misclassifies kick transients | Med | Only use transients with `confidence > 0.6`; add `KICK_CONFIDENCE_THRESHOLD = 0.6` constant. |
|
||||
| CC events overlap MIDI notes in E-line stream | Low | Sort all events (notes + CC) by absolute time; REAPER E-lines are monotonic delta-encoded. |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Delete `midi_cc` from `ClipDef` and revert builder to skip CC events. Remove `_get_kick_cache()` from compose.py. No schema migrations needed — `midi_cc` defaults to empty list (zero behavioral change). One-commit revert.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `librosa` (already a project dependency via `drum_analyzer.py`)
|
||||
- `DrumLoopAnalyzer` (already implemented and tested)
|
||||
- No new packages, no external APIs.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Bass MIDI clips contain CC11 (Expression) E-lines at kick hit positions
|
||||
- [ ] CC11 value drops to ~50 at kick onset, recovers to 127 within 0.18 beats
|
||||
- [ ] DrumLoopAnalyzer correctly identifies kick transients in all 5 drumloop variants
|
||||
- [ ] Kick position cache avoids re-analyzing same WAV across sections
|
||||
- [ ] 110 existing tests pass unchanged
|
||||
- [ ] `.rpp` output opens in REAPER without errors; bass audibly ducks when kick hits
|
||||
- [ ] `validate_rpp_output()` reports no regressions
|
||||
76
.sdd/changes/sidechain/spec.md
Normal file
76
.sdd/changes/sidechain/spec.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Delta Spec: 808 Bass Sidechain Ducking
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: MIDI CC11 Event Data Model
|
||||
|
||||
The schema MUST support an `CCEvent` dataclass with controller, time, and value fields, and `ClipDef` MUST accept an optional `midi_cc: list[CCEvent]` field defaulting to empty list.
|
||||
|
||||
#### Scenario: CCEvent round-trips correctly
|
||||
- GIVEN `CCEvent(controller=11, time=0.5, value=50)`
|
||||
- WHEN serialized/deserialized via dataclass
|
||||
- THEN all fields preserved exactly
|
||||
|
||||
#### Scenario: ClipDef with midi_cc
|
||||
- GIVEN a `ClipDef` with `midi_cc=[CCEvent(11, 0.0, 50), CCEvent(11, 0.18, 127)]`
|
||||
- WHEN clip is processed by builder
|
||||
- THEN builder sees `midi_cc` field and can iterate it
|
||||
|
||||
### Requirement: Kick Position Cache
|
||||
|
||||
A kick-cache dict `{drumloop_wav_path: list[beat_positions]}` SHALL be computed once per session, keyed by WAV path. `DrumLoopAnalyzer.transient_positions("kick")` MUST be the source, filtered by `confidence >= KICK_CONFIDENCE_THRESHOLD` (default 0.6).
|
||||
|
||||
#### Scenario: Cache hit
|
||||
- GIVEN drumloop WAV already analyzed in same session
|
||||
- WHEN `build_bass_track()` requests kick positions for that path
|
||||
- THEN cached positions returned without re-analyzing WAV
|
||||
|
||||
#### Scenario: Cache miss
|
||||
- GIVEN drumloop WAV not yet cached
|
||||
- WHEN kick positions requested
|
||||
- THEN `DrumLoopAnalyzer.analyze()` runs, positions cached by path key
|
||||
|
||||
### Requirement: CC11 Ducking on Kick Hits
|
||||
|
||||
For each kick transient position in the bass clip's time span, the system MUST emit CC11 events forming a ducking envelope: instantaneous drop to value 50 at kick time, hold at 50 for 0.02 beats, ramp to 127 by 0.18 beats after kick.
|
||||
|
||||
#### Scenario: Single kick duck
|
||||
- GIVEN kick at beat 1.0 within a 4-beat bass clip
|
||||
- WHEN CC events generated
|
||||
- THEN emits `CCEvent(11, 1.0, 50)`, `CCEvent(11, 1.02, 50)`, `CCEvent(11, 1.18, 127)`
|
||||
|
||||
#### Scenario: No kicks in clip
|
||||
- GIVEN drumloop with no kick transients in clip time range
|
||||
- THEN `midi_cc` is empty list — no CC events emitted
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: RPPBuilder MIDI Source Encoding
|
||||
|
||||
`_build_midi_source()` MUST emit MIDI CC events as `E B0 0B xx` lines interleaved with note events, all sorted by absolute start time. Delta-encoding MUST continue for all E-lines.
|
||||
|
||||
#### Scenario: CC events interleaved with notes
|
||||
- GIVEN clip with `midi_notes=[Note(60, 0.5, 1.0)]` and `midi_cc=[CCEvent(11, 0.0, 50)]`
|
||||
- WHEN `_build_midi_source()` called
|
||||
- THEN E-lines emitted in time order: CC at 0.0, Note at 0.5
|
||||
- AND CC line reads `E 0 B0 0B 32` (delta=0, CC11, value=50=0x32)
|
||||
|
||||
#### Scenario: Delta sequencing across note+CC
|
||||
- GIVEN CC at 0.0, note at 0.5 beats
|
||||
- WHEN building E-lines
|
||||
- THEN CC delta = 0×960 = 0; note delta = 0.5×960 - 0 = 480
|
||||
- AND cursor reset correctly after CC event ticks
|
||||
|
||||
### Requirement: Bass Track Generation
|
||||
|
||||
`build_bass_track()` SHALL accept an optional `kick_cache: dict[str, list[float]]` parameter. When kick data is present for the drumloop used in each section, `midi_cc` events SHALL be generated and added to the bass `ClipDef`.
|
||||
|
||||
#### Scenario: Bass clip with ducking CC
|
||||
- GIVEN kick cache has `[1.0, 2.5]` for drumloop, section covers beats 0-16
|
||||
- WHEN bass track built
|
||||
- THEN bass clip at that section has `midi_cc` with 2×3 CC events (one envelope per kick in range)
|
||||
- AND note generation unchanged from existing behavior
|
||||
|
||||
#### Scenario: No kick cache provided
|
||||
- GIVEN `kick_cache` is `{}` or omitted
|
||||
- THEN `midi_cc` is empty — zero behavioral change from current output
|
||||
26
.sdd/changes/sidechain/tasks.md
Normal file
26
.sdd/changes/sidechain/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Tasks: 808 Bass Sidechain Ducking
|
||||
|
||||
## Phase 1: Schema — Foundation
|
||||
|
||||
- [x] 1.1 Add `CCEvent` dataclass (`controller: int`, `time: float`, `value: int`) to `src/core/schema.py`
|
||||
- [x] 1.2 Add `midi_cc: list[CCEvent] = field(default_factory=list)` to `ClipDef` in `src/core/schema.py`
|
||||
- [x] 1.3 Update `asdict` if used; verify `song.validate()` passes with empty `midi_cc`
|
||||
|
||||
## Phase 2: Kick Cache + CC Generation
|
||||
|
||||
- [x] 2.1 Add constants `_KICK_CONFIDENCE_THRESHOLD=0.6`, `_CC11_DIP=50`, `_CC11_HOLD=0.02`, `_CC11_RELEASE=0.18` to `scripts/compose.py`
|
||||
- [x] 2.2 Add `_get_kick_cache(drumloop_paths: list[str], bpm: float) -> dict[str, list[float]]` to `scripts/compose.py`
|
||||
- [x] 2.3 Modify `build_bass_track()` to accept `kick_cache: dict[str, list[float]]` parameter; generate CC events for kicks in range
|
||||
- [x] 2.4 Update `main()` to build kick cache from drumloop paths and pass to `build_bass_track()`
|
||||
|
||||
## Phase 3: Builder CC Emission
|
||||
|
||||
- [x] 3.1 Modify `_build_midi_source()` in `src/reaper_builder/__init__.py` to merge `notes + cc` events and emit `E B0 0B {value:02x}` lines
|
||||
- [x] 3.2 Verify delta cursor correctly advances across CC events (CC events contribute zero ticks)
|
||||
|
||||
## Phase 4: Testing
|
||||
|
||||
- [x] 4.1 Unit test `CCEvent` dataclass round-trip in `tests/test_schema.py`
|
||||
- [x] 4.2 Unit test `_build_midi_source()` emits `B0 0B` lines for clips with `midi_cc`
|
||||
- [x] 4.3 Integration test `build_bass_track()` populates `midi_cc` when kick cache present
|
||||
- [x] 4.4 Regression: run existing 261 tests, verify all pass unchanged
|
||||
Reference in New Issue
Block a user