- 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.
4.0 KiB
4.0 KiB
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
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, modifyprocessor.osc.type,processor.filter.cutoff,processor.fx_transform_decapitator(chunks, role)— decode text body, modifyDrive,Tone,Stylelines_transform_omnisphere(chunks, role)— decode SynthMaster body, modifyAtk,Dec,Filter Freqlines
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.