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:
80
.sdd/changes/presets-pack/design.md
Normal file
80
.sdd/changes/presets-pack/design.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Design: presets-pack
|
||||
|
||||
## Technical Approach
|
||||
|
||||
Restructure `PLUGIN_PRESETS` to `{(plugin, role): chunks}`, add `PresetTransformer` class with per-plugin decoders (Serum=JSON, SoundToys=key=value text, Omnisphere=SynthMaster text), and thread `role` parameter through `make_plugin()` → `_build_plugin()`. No new dependencies — pure Python `base64`, `json`, `re`.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Data structure | `dict[tuple[str,str], list[str]]` — `{(plugin, role): chunks}` | Avoids dict-of-dicts nesting; simpler iteration in tests |
|
||||
| Transformation | Separate `PresetTransformer` class per plugin format | Serum/JSON, SoundToys/text, Omnisphere/text are different parsers; isolation = testability |
|
||||
| Role threading | Optional `role` param on `make_plugin()` and `_build_plugin()` | Zero breaking changes; None = current behavior |
|
||||
| Fallback chain | role → default → None | Backward compatible; existing tests don't break |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
compose.py: make_plugin("Serum_2", 0, role="bass")
|
||||
→ _resolve_preset("Serum_2", "bass")
|
||||
→ PLUGIN_PRESETS[("Serum_2", "bass")] ← PresetTransformer output
|
||||
→ PluginDef(preset_data=bass_chunks)
|
||||
|
||||
reaper_builder: _build_plugin(plugin)
|
||||
→ entry = PLUGIN_REGISTRY.get(resolved_name)
|
||||
→ preset_data = PLUGIN_PRESETS.get((resolved_name, plugin.role))
|
||||
or PLUGIN_PRESETS.get((resolved_name, "default"))
|
||||
→ _build_plugin_element(display, file, uid, preset_data)
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/reaper_builder/__init__.py` | Modify | Restructure `PLUGIN_PRESETS` to `{(k,role): chunks}`; `_build_plugin()` reads `plugin.role` for lookup |
|
||||
| `src/reaper_builder/preset_transformer.py` | Create | `PresetTransformer` class with `decode()`, `transform(role)`, `encode()`; per-plugin transformers |
|
||||
| `src/composer/templates.py` | Modify | `_parse_vst_block()` and `_make_plugin_template()` handle new `PLUGIN_PRESETS` structure |
|
||||
| `scripts/compose.py` | Modify | `make_plugin()` accepts `role` param, threads from `FX_CHAINS` key |
|
||||
| `src/core/schema.py` | Modify | `PluginDef` gets optional `role: str \| None = None` field |
|
||||
| `tests/test_preset_transform.py` | Create | Round-trip tests for 3 plugins × N roles |
|
||||
|
||||
## PresetTransformer Design
|
||||
|
||||
```python
|
||||
class PresetTransformer:
|
||||
TRANSFORMERS: dict[str, Callable] = {
|
||||
"Serum_2": _transform_serum,
|
||||
"Decapitator": _transform_decapitator,
|
||||
"Omnisphere": _transform_omnisphere,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def derive(plugin: str, default_chunks: list[str], role: str) -> list[str]:
|
||||
transformer = PresetTransformer.TRANSFORMERS.get(plugin)
|
||||
if not transformer:
|
||||
return default_chunks # no transform = use default unchanged
|
||||
return transformer(default_chunks, role)
|
||||
```
|
||||
|
||||
Per-transformer functions:
|
||||
- `_transform_serum(chunks, role)` — decode JSON body, modify `processor.osc.type`, `processor.filter.cutoff`, `processor.fx`
|
||||
- `_transform_decapitator(chunks, role)` — decode text body, modify `Drive`, `Tone`, `Style` lines
|
||||
- `_transform_omnisphere(chunks, role)` — decode SynthMaster body, modify `Atk`, `Dec`, `Filter Freq` lines
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Layer | What | Approach |
|
||||
|-------|------|----------|
|
||||
| Unit | PresetTransformer per plugin | decode → modify → encode for each (plugin, role); verify JSON/keys changed |
|
||||
| Integration | make_plugin + _build_plugin with role | Build PluginDef, verify preset_data differs per role |
|
||||
| Regression | `test_make_plugin_known_key` | Existing tests pass unchanged (role=None fallback) |
|
||||
| Round-trip | encode(decode(chunks)) == chunks | Each plugin × role; verify chunk count, base64 charset, structure integrity |
|
||||
|
||||
## Migration / Rollout
|
||||
|
||||
No migration required. `PluginDef.role` defaults to None. Existing callers that don't pass role continue working with `"default"` preset. Revert: flatten `PLUGIN_PRESETS` back to single-level dict, remove `role` param.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
Reference in New Issue
Block a user