- 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.
83 lines
4.0 KiB
Markdown
83 lines
4.0 KiB
Markdown
# 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.
|