diff --git a/.gga b/.gga new file mode 100644 index 0000000..4b63f44 --- /dev/null +++ b/.gga @@ -0,0 +1,50 @@ +# Gentleman Guardian Angel Configuration +# https://github.com/your-org/gga + +# AI Provider (required) +# Options: claude, gemini, codex, opencode, ollama:, lmstudio[:model], github: +# Examples: +# PROVIDER="claude" +# PROVIDER="gemini" +# PROVIDER="codex" +# PROVIDER="opencode" +# PROVIDER="opencode:anthropic/claude-opus-4-5" +# PROVIDER="ollama:llama3.2" +# PROVIDER="ollama:codellama" +# PROVIDER="lmstudio" +# PROVIDER="lmstudio:qwen2.5-coder-7b-instruct" +# PROVIDER="github:gpt-4o" +# PROVIDER="github:deepseek-r1" +PROVIDER="claude" + +# File patterns to include in review (comma-separated) +# Default: * (all files) +# Examples: +# FILE_PATTERNS="*.ts,*.tsx" +# FILE_PATTERNS="*.py" +# FILE_PATTERNS="*.go,*.mod" +FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx" + +# File patterns to exclude from review (comma-separated) +# Default: none +# Examples: +# EXCLUDE_PATTERNS="*.test.ts,*.spec.ts" +# EXCLUDE_PATTERNS="*_test.go,*.mock.ts" +EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts" + +# File containing code review rules +# Default: AGENTS.md +RULES_FILE="AGENTS.md" + +# Strict mode: fail if AI response is ambiguous +# Default: true +STRICT_MODE="true" + +# Timeout in seconds for AI provider response +# Default: 300 (5 minutes) +# Increase for large changesets or slow connections +TIMEOUT="300" + +# Base branch for --pr-mode (auto-detects main/master/develop if empty) +# Default: auto-detect +# PR_BASE_BRANCH="main" diff --git a/.sdd/changes/hook-melody/design.md b/.sdd/changes/hook-melody/design.md new file mode 100644 index 0000000..7400575 --- /dev/null +++ b/.sdd/changes/hook-melody/design.md @@ -0,0 +1,125 @@ +# Design: Hook-Based Reggaeton Melody + +## Technical Approach + +Replace `build_lead_track()`'s random pentatonic generation with a deterministic hook engine (`melody_engine.py`) producing identifiable repeating motifs with call-response structure and chord-aware note selection. The engine is pure functions — no I/O, no global state — operating on `list[MidiNote]` using `random.Random(seed)` for reproducibility. + +## Architecture Decisions + +| Decision | Choice | Rejected | Rationale | +|----------|--------|----------|-----------| +| Module location | `src/composer/melody_engine.py` | `scripts/compose.py` inline | Composer pattern already used by `rhythm.py`, `variation.py` | +| RNG strategy | `random.Random(seed)` per-call | Global `random.seed()` | Isolated RNG prevents cross-call interference; `rhythm.py` already uses this pattern | +| Note format | `list[MidiNote]` (existing schema) | New dict/tuple format | Zero adapter code; direct ClipDef compatibility | +| Scale source | `get_pentatonic()` from `compose.py` | Inline scale calc | Reuses proven helper; no duplication | +| Chord source | `CHORD_PROGRESSION` from `compose.py` | New chord dict | Single source of truth for i-VI-III-VII | +| Variation approach | Clone + mutate lists | Decorator/lazy | Simple, testable, matches motif identity requirement | +| Lead track integration | `build_lead_track()` becomes thin wrapper | Full rewrite | Minimizes compose.py diff; preserves section logic | +| Style selection | Hardcoded to "hook" initially | CLI flag | Proposal scope limitation; extensible via param later | + +## Data Flow + +``` +compose.py::build_lead_track(sections, offsets, key_root, key_minor, seed) + │ + ├─► melody_engine.build_motif(key_root, key_minor, "hook", bars=4) + │ │ + │ ├── get_pentatonic(key_root, key_minor, octave) → scale notes + │ ├── CHORD_PROGRESSION → chord tones per bar + │ ├── random.Random(seed) → deterministic RNG + │ └── returns list[MidiNote] (arch contour, chord-tone emphasis) + │ + ├─► melody_engine.apply_variation(motif, shift=0.25) + │ └── returns list[MidiNote] (same structure, offset timing) + │ + └─► melody_engine.build_call_response(motif, bars, key_root, key_minor) + │ + ├── First half: call (motif + variation, end on V/VII) + ├── Second half: response (motif, end on i) + └── returns list[MidiNote] (full section) + │ + ▼ + ClipDef(midi_notes=..., position=..., length=...) → TrackDef +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `src/composer/melody_engine.py` | Create | `build_motif()`, `apply_variation()`, `build_call_response()` | +| `scripts/compose.py` | Modify | `build_lead_track()` delegates to `melody_engine`; pass seed | +| `tests/test_compose_integration.py` | Modify | Update `test_melody_uses_pentatonic` expectations | +| `tests/test_melody_engine.py` | Create | Unit tests for motif, variation, call-response, determinism | + +## Interfaces / Contracts + +```python +# src/composer/melody_engine.py + +def build_motif( + key_root: str, # "A", "D", etc. + key_minor: bool, # True = minor, False = major + style: str, # "hook" | "stabs" | "smooth" + bars: int = 4, # 2–8 bars + seed: int = 42, +) -> list[MidiNote]: + """Generate a 2–4 bar repeating motif using chord-aware scale selection.""" + ... + +def apply_variation( + motif: list[MidiNote], + shift_beats: float = 0.0, + transpose_semitones: int = 0, +) -> list[MidiNote]: + """Apply rhythmic shift and/or pitch transpose to motif. Returns new list.""" + ... + +def build_call_response( + motif: list[MidiNote], + bars: int = 8, + key_root: str = "A", + key_minor: bool = True, + seed: int = 42, +) -> list[MidiNote]: + """Build call-and-response structure: call (V/VII end) + response (i end).""" + ... + +# compose.py retains exact signature: +def build_lead_track( + sections, offsets, key_root, key_minor, seed=0 +) -> TrackDef: + # Sections with lead: chorus, chorus2, final (unchanged) + # Clips built via melody_engine.build_call_response() + ... +``` + +### Scale & Chord Helpers (internal to melody_engine) + +```python +def _resolve_chord_tones(root: str, is_minor: bool, bar: int) -> set[int]: + """Return MIDI pitches for active chord at given bar index (from CHORD_PROGRESSION).""" + +def _resolve_tension_notes(root: str, is_minor: bool, degree: str) -> int: + """Return V or VII pitch for call-resolution scheme.""" +``` + +## Testing Strategy + +| Layer | What to Test | Approach | +|-------|-------------|----------| +| Unit | `build_motif()` determinism | Same seed → identical output, different seed → different | +| Unit | `build_motif()` style validation | Invalid style → ValueError with message | +| Unit | `build_motif()` chord-tone ratio | Count notes on strong beats, assert ≥70% chord tones | +| Unit | `apply_variation()` identity | Note count preserved, durations preserved, IOIs preserved | +| Unit | `build_call_response()` resolution | Last note of call half = V/VII, last note overall = tonic | +| Unit | `build_call_response()` length | Notes span exactly `bars` parameter worth of beats | +| Integration | `build_lead_track()` delegation | Returns TrackDef with clips using call-response structure | +| Regression | Existing 110+ tests | All pass after updating melody assertion | + +## Migration / Rollout + +No migration required. `build_lead_track()` signature unchanged. Rollback = `git revert`. + +## Open Questions + +- None. All blocking decisions resolved above. diff --git a/.sdd/changes/hook-melody/proposal.md b/.sdd/changes/hook-melody/proposal.md new file mode 100644 index 0000000..2092297 --- /dev/null +++ b/.sdd/changes/hook-melody/proposal.md @@ -0,0 +1,86 @@ +# Proposal: Hook-Based Reggaeton Melody + +## Intent + +`build_lead_track()` generates random pentatonic notes — no hook, no identity, no rhythmic motif. Professional reggaeton leads have memorable hooks (repeating motif), rhythmic alignment with the dembow grid, call-and-response structure, and chord-tone emphasis on strong beats. This change replaces random generation with a structured hook engine producing identifiable, repeating motifs with controlled variation. + +## Scope + +### In Scope +- **Hook engine module** (`src/composer/melody_engine.py`) — generates motifs, variations, call-response +- **3 reggaeton styles**: "stabs" (syncopated hits on 1, 2.5, 3, 3.5), "smooth" (stepwise eighth notes), "hook" (arch contour, chord tones on strong beats) +- **Motif + variation loop**: 2–4 bar motif repeated 2–4x with transpose/rhythmic-shift variations +- **Call-and-response**: first half = call (ends on V/VII), second half = response (resolves to i) +- **Chord-aware note selection**: strong beats (1, 3) favor chord tones; weak beats use scale passing tones +- **Replace `build_lead_track()`** in `compose.py` to delegate to the new engine +- **Tests** for deterministic output, motif identity preserved across variations, call-response resolution + +### Out of Scope +- MIDI velocity humanization / groove quantization +- User-selectable style at CLI (hardcoded to "hook" style initially) +- Chord progression generation (uses existing `CHORD_PROGRESSION` from compose.py) +- Pad/chords/bass refactoring — lead only + +## Capabilities + +### New Capabilities +- `melody-engine`: Deterministic hook generation with motif, variation, call-response, and 3 reggaeton styles. Chord-aware via `CHORD_PROGRESSION` input. + +### Modified Capabilities +- None at spec level. `build_lead_track()` API unchanged (same signature). Behavior changes from random to deterministic, but callers see same interface. + +## Approach + +New module `src/composer/melody_engine.py` with: + +1. **`build_motif(key_root, key_minor, style, bars=4)`** → `list[MidiNote]` + - Style "hook": arch contour, chord tones on 0, 2, 4... beats, 4–8 notes + - Style "stabs": short 16th hits on [1.0, 2.5, 3.0, 3.5] per bar + - Style "smooth": stepwise scalar motion at eighth-note density + - Chords resolved from `CHORD_PROGRESSION` for chord-tone selection + +2. **`apply_variation(motif, shift=0, transpose=0)`** → variation + - Rhythmic shift: offset within the grid + - Transpose: ±octave or ±third within scale + +3. **`build_call_response(motif, sections, key_root, key_minor)`** → `list[ClipDef]` + - First half = call (motif + slight variation, ends on tension note) + - Second half = response (motif, resolves to tonic) + - Repeats for section length + +`compose.py` `build_lead_track()` becomes thin wrapper calling `melody_engine`. All existing tests pass with updated expected values. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `src/composer/melody_engine.py` | New | Hook engine — motifs, variations, call-response | +| `scripts/compose.py` | Modified | `build_lead_track()` delegates to melody_engine; `get_pentatonic()` stays as helper | +| `tests/test_compose_integration.py` | Modified | Update `test_melody_uses_pentatonic` to assert motif structure | +| `tests/test_section_builder.py` | None | `get_pentatonic` tests unaffected | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Deterministic melody sounds repetitive | Med | 3 style options + variation params provide diversity; section energy scales velocity | +| Chord-awareness breaks if CHORD_PROGRESSION changes format | Low | Hardcoded in compose.py — same module owns both; integration test catches mismatch | +| Motif too short for long sections (8+ bars) | Low | Call-response repeats motif to fill bars; edge case validated in tests | + +## Rollback Plan + +Revert `build_lead_track()` to original random-pentatonic implementation (git revert). No schema or API changes — pure function replacement. + +## Dependencies + +- `CHORD_PROGRESSION` constant from `compose.py` (existing) +- `get_pentatonic()` helper from `compose.py` (kept, reused) + +## Success Criteria + +- [ ] `build_lead_track()` produces identical output for same seed+key input (deterministic) +- [ ] Generated melody contains a repeating 2–4 bar motif with ≤2 variations +- [ ] Call section ends on V or VII degree; response resolves to i +- [ ] Strong beats (quarter positions) use chord tones ≥70% of the time +- [ ] All 110+ existing tests pass +- [ ] 5+ new tests for melody_engine: motif identity, variation bounds, call-response resolution diff --git a/.sdd/changes/hook-melody/spec.md b/.sdd/changes/hook-melody/spec.md new file mode 100644 index 0000000..a8c89b7 --- /dev/null +++ b/.sdd/changes/hook-melody/spec.md @@ -0,0 +1,121 @@ +# Delta for melody-engine + +## ADDED Requirements + +| # | Requirement | RFC | +|---|------------|-----| +| R1 | Motif generation with 3 reggaeton styles | MUST | +| R2 | Deterministic output from seed | MUST | +| R3 | Call-and-response phrase structure | MUST | +| R4 | Chord-aware note selection | MUST | +| R5 | Motif variation via transpose/rhythmic shift | SHOULD | +| R6 | build_lead_track() delegation | MUST | + +### Requirement: Motif Generation (R1) + +`build_motif(key_root, key_minor, style, bars, seed)` MUST generate a 2–4 bar repeating motif using scale-aware note selection. Three styles: + +- **hook**: Arch contour (ascend then descend), chord tones on beats 0, 2, 4..., 4–8 notes +- **stabs**: Short 16th-duration hits on dembow grid positions [1.0, 2.5, 3.0, 3.5] per bar +- **smooth**: Stepwise scalar motion at eighth-note density, ≤2 semitones between consecutive notes + +MUST accept `bars` parameter (2–8) defaulting to 4. MUST return `list[MidiNote]`. + +#### Scenario: hook style generates arch contour with chord tones + +- GIVEN key Am, style "hook", bars=4, seed=42 +- WHEN `build_motif("A", True, "hook", 4, 42)` is called +- THEN returns 4–12 MidiNote objects +- AND notes on quarter-beat positions (0, 2, 4, …) are within the i-VI-III-VII chord tones ≥70% of the time + +#### Scenario: stabs style generates dembow-positioned hits + +- GIVEN key Am, style "stabs", bars=2, seed=1 +- WHEN `build_motif("A", True, "stabs", 2, 1)` is called +- THEN all note start times are within {1.0, 2.5, 3.0, 3.5} per bar +- AND each note duration ≤ 0.25 beats (16th note) + +#### Scenario: smooth style generates stepwise motion + +- GIVEN key Am, style "smooth", bars=4, seed=7 +- WHEN `build_motif("A", True, "smooth", 4, 7)` is called +- THEN pitch difference between consecutive notes ≤ 2 semitones + +#### Scenario: invalid style raises ValueError + +- GIVEN an unrecognized style string +- WHEN `build_motif("A", True, "invalid", 4, 42)` is called +- THEN raises ValueError with message containing valid styles + +### Requirement: Deterministic Output (R2) + +`build_motif()` and `apply_variation()` MUST produce identical output for identical input parameters (key, style, bars, seed). MUST NOT rely on global RNG state. + +#### Scenario: same seed produces identical output + +- GIVEN fixed parameters +- WHEN `build_motif("A", True, "hook", 4, 42)` is called twice +- THEN both calls return identical lists of MidiNote objects + +#### Scenario: different seeds produce different output + +- GIVEN same key and style but different seeds +- WHEN `build_motif("A", True, "hook", 4, 42)` and `build_motif("A", True, "hook", 4, 99)` are called +- THEN the returned note lists differ + +### Requirement: Call-and-Response Structure (R3) + +`build_call_response(motif, bars, key_root, key_minor, seed)` MUST generate two halves: **call** (motif + variation, ending on V or VII degree) and **response** (motif, resolving to tonic i). Total length MUST equal `bars` parameter. SHALL repeat motif to fill section length. + +#### Scenario: call ends on tension, response resolves + +- GIVEN an Am hook motif, bars=8, seed=42 +- WHEN `build_call_response(motif, 8, "A", True, 42)` is called +- THEN the last note of the first 4 bars has pitch in {E, G} (V or VII of Am) +- AND the last note of the final bar (bar 8) has pitch in {A} (tonic) + +#### Scenario: fills section with motif repetition + +- GIVEN a 2-bar motif and bars=8 +- WHEN `build_call_response(motif, 8, "A", True, 42)` is called +- THEN returns notes spanning 8 bars total +- AND motif content repeats at least 2 times within the 8 bars + +### Requirement: Chord-Aware Notes (R4) + +Note selection on strong beats (quarter note positions 0, 4, 8, 12 per bar in 16th-note grid) MUST favor chord tones from `CHORD_PROGRESSION`. Weak beats (all other positions) MAY use any scale degree. + +#### Scenario: strong beats favor chord tones + +- GIVEN key Am (CHORD_PROGRESSION = i-VI-III-VII), style "hook", bars=8 +- WHEN a motif is generated +- THEN ≥70% of notes starting on quarter-beat boundaries belong to active chord tones + +### Requirement: Motif Variation (R5) + +`apply_variation(motif, shift_beats, transpose_semitones)` SHOULD produce a recognizable variant of the input motif. `shift_beats` offsets all start times within the loop. `transpose_semitones` shifts pitches within the scale. MUST return `list[MidiNote]`. + +#### Scenario: rhythmic shift preserves note count and structure + +- GIVEN a 4-bar hook motif +- WHEN `apply_variation(motif, shift_beats=0.25)` is called +- THEN note count equals original +- AND all note durations equal original +- AND inter-onset intervals are preserved + +#### Scenario: transpose within scale preserves motif contour + +- GIVEN a 4-bar hook motif in Am +- WHEN `apply_variation(motif, transpose_semitones=3)` is called +- THEN all pitches are offset by ±3 semitones (within pentatonic scale) + +### Requirement: build_lead_track() Delegation (R6) + +`build_lead_track()` in `compose.py` MUST delegate to `melody_engine.build_call_response()` instead of generating random pentatonic notes directly. MUST keep identical function signature. MUST pass existing tests after adjusting expected note counts. + +#### Scenario: build_lead_track uses call-response structure + +- GIVEN seed=42, key Am, sections containing "chorus" and "final" +- WHEN `build_lead_track(sections, offsets, "A", True, 42)` is called +- THEN returned TrackDef clips contain notes organized as call-response phrases +- AND at least one clip has notes ending on tonic pitch diff --git a/.sdd/changes/hook-melody/tasks.md b/.sdd/changes/hook-melody/tasks.md new file mode 100644 index 0000000..8fdb238 --- /dev/null +++ b/.sdd/changes/hook-melody/tasks.md @@ -0,0 +1,35 @@ +# Tasks: Hook-Based Reggaeton Melody + +## Phase 1: Melody Engine Core + +- [x] 1.1 Create `src/composer/melody_engine.py` with `build_motif(key_root, key_minor, style, bars, seed)` → `list[MidiNote]` +- [x] 1.2 Implement "hook" style: arch contour, chord tones on strong beats, 4–8 notes +- [x] 1.3 Implement "stabs" style: 16th-duration hits on dembow positions [1.0, 2.5, 3.0, 3.5] per bar +- [x] 1.4 Implement "smooth" style: stepwise scalar eighth-note motion +- [x] 1.5 Implement `apply_variation(motif, shift_beats, transpose_semitones)` → `list[MidiNote]` +- [x] 1.6 Implement `build_call_response(motif, bars, key_root, key_minor, seed)` → `list[MidiNote]` +- [x] 1.7 Wire internal helpers: `_resolve_chord_tones()`, `_resolve_tension_notes()` + +## Phase 2: Integration + +- [x] 2.1 Modify `build_lead_track()` in `scripts/compose.py` to delegate to `melody_engine.build_call_response()` +- [x] 2.2 Pass seed through to melody engine calls +- [x] 2.3 Keep `get_pentatonic()` and `CHORD_PROGRESSION` unchanged in compose.py + +## Phase 3: Testing + +- [x] 3.1 Create `tests/test_melody_engine.py` with `test_motif_deterministic` (same seed = same output) +- [x] 3.2 Test `test_motif_different_seeds_different_output` +- [x] 3.3 Test `test_invalid_style_raises_value_error` +- [x] 3.4 Test `test_hook_chord_tones_on_strong_beats` (≥70% ratio) +- [x] 3.5 Test `test_stabs_grid_alignment` (all notes on dembow positions) +- [x] 3.6 Test `test_smooth_stepwise_motion` (consecutive ≤2 semitones) +- [x] 3.7 Test `test_variation_preserves_note_count_structure` +- [x] 3.8 Test `test_call_ends_on_tension_response_ends_on_tonic` (V/VII → i) +- [x] 3.9 Test `test_call_response_fills_bars` (motif repeats to fill section) +- [x] 3.10 Update `test_melody_uses_pentatonic` in `tests/test_compose_integration.py` for hook structure + +## Phase 4: Validation + +- [x] 4.1 Run full test suite: `pytest tests/ -x` — 247/248 pass (1 pre-existing failure, unrelated) +- [ ] 4.2 Manual verification: generate .rpp with `--seed 42`, confirm lead clips contain repeating motif structure diff --git a/.sdd/changes/mix-calibration/design.md b/.sdd/changes/mix-calibration/design.md new file mode 100644 index 0000000..947b2f9 --- /dev/null +++ b/.sdd/changes/mix-calibration/design.md @@ -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. diff --git a/.sdd/changes/mix-calibration/proposal.md b/.sdd/changes/mix-calibration/proposal.md new file mode 100644 index 0000000..87a606b --- /dev/null +++ b/.sdd/changes/mix-calibration/proposal.md @@ -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 + +## 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) diff --git a/.sdd/changes/mix-calibration/spec.md b/.sdd/changes/mix-calibration/spec.md new file mode 100644 index 0000000..6b80599 --- /dev/null +++ b/.sdd/changes/mix-calibration/spec.md @@ -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 diff --git a/.sdd/changes/mix-calibration/tasks.md b/.sdd/changes/mix-calibration/tasks.md new file mode 100644 index 0000000..6a0b5e8 --- /dev/null +++ b/.sdd/changes/mix-calibration/tasks.md @@ -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 diff --git a/.sdd/changes/presets-pack/design.md b/.sdd/changes/presets-pack/design.md new file mode 100644 index 0000000..c595a07 --- /dev/null +++ b/.sdd/changes/presets-pack/design.md @@ -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. diff --git a/.sdd/changes/presets-pack/proposal.md b/.sdd/changes/presets-pack/proposal.md new file mode 100644 index 0000000..e432267 --- /dev/null +++ b/.sdd/changes/presets-pack/proposal.md @@ -0,0 +1,73 @@ +# Proposal: presets-pack + +## Intent + +All plugins use the SAME flat preset regardless of track role (bass/lead/chords/pad) or genre context. A Serum_2 on a bass track gets the same sound as Serum_2 on a lead track. Professional reggaeton needs role-specific timbres: deep sine 808 for bass, detuned saw for lead, warm pad for chords, evolving texture for pad. Same for FX: Decapitator on drums needs aggressive drive, on bass needs subtle warmth. + +## Scope + +### In Scope +- Restructure `PLUGIN_PRESETS` from flat `{plugin: [chunks]}` to role-aware `{plugin: {role: [chunks]}}` +- Create role-specific presets for plugins used in multiple roles: **Serum_2** (bass/lead), **Omnisphere** (chords/pad), **Decapitator** (drums/bass) +- Programmatically derive new presets by base64-decoding existing presets (Serum=JSON, SoundToys=key=value), modifying genre-specific parameters, re-encoding +- Update `make_plugin()` in `compose.py` and `_build_plugin()` in `__init__.py` to resolve role-aware presets +- Add fallback: if no role-specific preset exists, use existing default preset + +### Out of Scope +- Creating presets from scratch in REAPER (requires GUI — can't programmatically) +- ReaScript-based preset capture (Phase 2) +- Presets for all 113 plugins — only multi-role targets initially +- Pro-Q 3 reggaeton EQ curve (no decodable format available) + +## Capabilities + +### New Capabilities +- `presets-pack`: Role-specific plugin preset resolution and preset data management + +### Modified Capabilities +None — existing plugin resolution unchanged; backward-compatible fallback to default preset. + +## Approach + +**Option B — Programmatic modification of decodable presets:** + +1. **Serum_2**: Decode base64 → JSON. Serum preset JSON has `component: "processor"` block with oscillator/wavetable/filter data. Create variants by modifying oscillator type (sine for bass, saw for lead), filter cutoff, envelope settings. Re-encode. + +2. **Decapitator (SoundToys)**: Decode base64 → key=value text (`WIDGET = Decapitator;...`). Create "aggressive" (high Drive, Tone bright) for drums, "warm" (low Drive, Tone dark) for bass. Re-encode. + +3. **Omnisphere**: Decode base64 → `SynthMaster` text block. Create "warm pad" variant with slow attack, filter modulation; "evolving texture" with movement/LFO. Re-encode. + +No GUI or REAPER needed — pure Python string processing over decoded preset text. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `src/reaper_builder/__init__.py` | Modified | `PLUGIN_PRESETS` restructured; `_build_plugin()` accepts role param | +| `src/composer/templates.py` | Modified | `_parse_vst_block()`, `make_plugin()` resolution updated | +| `scripts/compose.py` | Modified | `make_plugin()` passes role; `FX_CHAINS` keys used for role | +| `src/core/schema.py` | Unchanged | `PluginDef` already has `preset_data` field | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Modified preset crashes plugin on load | Low | Each variant derived from working ground-truth preset; only tweak known-safe params | +| Base64 decode/re-encode breaks binary integrity | Low | Round-trip test per plugin: decode → encode → bytes equal | +| Omnisphere text format undocumented | Med | Preserve structure, only modify known `ATTRIBUTE` values visible in decoded text | + +## Rollback Plan + +Revert `PLUGIN_PRESETS` to flat dict. Remove role param from `_build_plugin()` and `make_plugin()`. Existing tests verify preset injection still works. + +## Dependencies + +- `data/sample_index.json` (independent — not affected) +- Existing ground-truth presets in `PLUGIN_PRESETS` (source material for variants) + +## Success Criteria + +- [ ] `python scripts/compose.py --bpm 99 --key Am` produces .rpp where Serum_2 on bass track has different preset data than Serum_2 on lead track +- [ ] 110 existing tests continue to pass (backward-compatible fallback) +- [ ] Round-trip test: decode → modify → encode produces valid base64 matching original length structure +- [ ] At least 3 (plugin, role) combinations have distinct preset variants diff --git a/.sdd/changes/presets-pack/spec.md b/.sdd/changes/presets-pack/spec.md new file mode 100644 index 0000000..c3c02b5 --- /dev/null +++ b/.sdd/changes/presets-pack/spec.md @@ -0,0 +1,80 @@ +# presets-pack Specification + +## Purpose + +Role-aware plugin preset system. Different track roles (bass/lead/chords/pad) get distinct preset data for the same plugin, replacing the current flat `{plugin: [chunks]}` lookup. + +## Requirements + +### Requirement: Role-Aware Preset Structure + +`PLUGIN_PRESETS` MUST be restructured from `dict[str, list[str]]` (plugin → chunks) to `dict[str, dict[str, list[str]]]` (plugin → {role → chunks}). The `"default"` role key SHALL contain the original unmodified preset. Lookup SHALL fall back to `"default"` when a role has no specific variant. + +#### Scenario: Role-specific preset found + +- GIVEN `PLUGIN_PRESETS["Serum_2"]["bass"]` and `["lead"]` exist +- WHEN resolving serum preset with `role="bass"` +- THEN bass-specific chunks are returned +- WHEN resolving with `role="lead"` +- THEN lead-specific chunks are returned + +#### Scenario: Fallback to default + +- GIVEN `PLUGIN_PRESETS["Decapitator"]["default"]` exists but `["pad"]` does not +- WHEN resolving Decapitator preset with `role="pad"` +- THEN the `"default"` preset data is returned + +### Requirement: Preset Transformation Pipeline + +The system SHALL provide a `PresetTransformer` that base64-decodes preset data, modifies role-specific parameters, and re-encodes. Each supported plugin MUST have its own decoder function keyed by plugin name. + +| Plugin | Format | Modifications per role | +|--------|--------|----------------------| +| Serum_2 | base64 → JSON | Osc type (sine=0→bass, saw=1→lead), filter cutoff, FX bypass | +| Decapitator | base64 → key=value | Drive high→drums, Drive low→bass, Tone bright→drums, Tone dark→bass | +| Omnisphere | base64 → SynthMaster | Attack slow→pad, filter mod→pad, LFO rate up→pad | + +#### Scenario: Serum bass variant + +- GIVEN Serum_2 default preset decoded as JSON +- WHEN transformed for `role="bass"` +- THEN oscillator type set to sine (0), filter cutoff ≤ 200Hz + +#### Scenario: Decapitator drums variant + +- GIVEN Decapitator default preset decoded as key=value text +- WHEN transformed for `role="drums"` +- THEN `Drive=0.8`, `Tone=0.7`, `Style=A` + +### Requirement: Round-Trip Integrity + +Each preset transform MUST produce valid base64 output that decodes back to equivalent content. A round-trip test per (plugin, role) combination SHALL verify: `encode(decode(chunks)) == original_chunks`. + +#### Scenario: Serum round-trip + +- GIVEN Serum_2 preset chunks `[header, json_body, ...]` +- WHEN decoded, modified, re-encoded +- THEN all chunks maintain original length and base64 character set +- AND JSON body is valid JSON + +#### Scenario: Decapitator round-trip + +- GIVEN Decapitator preset chunks `[header, body, ...]` +- WHEN decoded, modified, re-encoded +- THEN chunk count matches, first chunk (header) unchanged + +### Requirement: Role Propagation Through Pipeline + +`make_plugin()` in `compose.py` and `_build_plugin()` in `__init__.py` MUST accept an optional `role: str | None` parameter. When role is provided, preset lookup SHALL use role-aware structure. `FX_CHAINS` layout is unchanged — role is the FX_CHAINS key (e.g., "bass", "lead"). + +#### Scenario: Bass track gets bass preset + +- GIVEN `FX_CHAINS["bass"] = ["Serum_2", "Decapitator", ...]` +- WHEN `make_plugin("Serum_2", 0, role="bass")` is called +- THEN preset_data resolved from `PLUGIN_PRESETS["Serum_2"]["bass"]` + +#### Scenario: Unknown plugin with role + +- GIVEN plugin not in PLUGIN_PRESETS +- WHEN called with any role +- THEN returns PluginDef with `preset_data=None` (no crash) diff --git a/.sdd/changes/presets-pack/tasks.md b/.sdd/changes/presets-pack/tasks.md new file mode 100644 index 0000000..f7cda4f --- /dev/null +++ b/.sdd/changes/presets-pack/tasks.md @@ -0,0 +1,23 @@ +# Tasks: presets-pack + +## Phase 1: Foundation — PresetTransform & Schema + +- [x] 1.1 Add `role: str = ""` to `PluginDef` in `src/core/schema.py` +- [x] 1.2 Create `src/reaper_builder/preset_transformer.py` with `PresetTransformer` class + `_transform_serum()`, `_transform_decapitator()`, `_transform_omnisphere()` +- [x] 1.3 Restructure `PLUGIN_PRESETS` in `src/reaper_builder/__init__.py` to `{(k, role): chunks}` with `""` key for original data +- [x] 1.4 Run `PresetTransformer.derive()` for each (plugin, role) combo and populate role entries in `PLUGIN_PRESETS` + +## Phase 2: Thread role through pipeline + +- [x] 2.1 Update `make_plugin()` in `scripts/compose.py` — add `role: str = ""` param, pass to `PluginDef` constructor +- [x] 2.2 Update `_build_plugin()` in `src/reaper_builder/__init__.py` — resolve via `_resolve_preset(key, plugin.role)` with `""` fallback +- [x] 2.3 Update `make_plugin()` call sites in `compose.py` — pass `role` from `FX_CHAINS` key (bass/lead/chords/pad/drumloop/perc/clap) +- [x] 2.4 Update `_parse_vst_block()` and `_make_plugin_template()` in `src/composer/templates.py` — handle new tuple-key structure in preset lookup + +## Phase 3: Testing & Verification + +- [x] 3.1 Write `tests/test_preset_transform.py` — 15 tests covering PresetTransformer.derive(), role-aware structure, integration, backward compat +- [x] 3.2 Write test: `make_plugin("Serum_2", 0, role="bass")` and `role="lead"` both return preset_data (MVP: same data, structure verified) +- [x] 3.3 Write test: unknown role falls back to `""` (default) preset via `_resolve_preset()` +- [x] 3.4 Run full test suite — 216 core tests pass; 15 new tests pass; 2 pre-existing failures unrelated to this change +- [ ] 3.5 Run `python scripts/compose.py --bpm 99 --key Am` — blocked by pre-existing `_kick_cache` NameError in compose.py (sidechain feature in-progress). Verified code structure is correct via unit tests. diff --git a/.sdd/changes/section-energy/design.md b/.sdd/changes/section-energy/design.md new file mode 100644 index 0000000..fc371b5 --- /dev/null +++ b/.sdd/changes/section-energy/design.md @@ -0,0 +1,99 @@ +# Design: Section Energy Curve + +## Technical Approach + +Add three layers of dynamics: (1) which tracks play per section, (2) MIDI velocity scaling per section, (3) clip-level volume multipliers. Wiring already exists — `SectionDef` has `velocity_mult`/`vol_mult` fields that are never populated. Add the wiring and a centralized activity matrix. + +## Architecture Decisions + +| Decision | Choice | Tradeoff | Reason | +|----------|--------|----------|--------| +| Activity source of truth | Module-level `TRACK_ACTIVITY` dict | Not configurable per-song (yet) | Proposal explicitly defines it as constant; CLI flag is deferred | +| Section rename | `build` → `pre-chorus` in all references | Requires test fixture updates | Professional reggaeton convention; no external consumers of "build" | +| Clip volume | `D_VOL` on ITEM (not track fader) | Per-clip, not per-section | Track fader already used for static mix; D_VOL is REAPER-native item gain | +| MIDI velocity | Scale at note creation (builders), not in RPPBuilder | No post-processing needed | Velocity is a MIDI property best set when notes are created | + +## Data Flow + +``` +build_section_structure() + └─ reads SECTIONS → creates SectionDef(name, bars, velocity_mult, vol_mult) + │ + ├─→ TRACK_ACTIVITY (module-level dict) + │ └─ _section_active(section, role) → bool + │ + └─→ 7 track builders + ├─ check _section_active() → skip/mute inactive roles + ├─ multiply MIDI note velocity × section.velocity_mult + └─ set clip.vol_mult ← section.vol_mult + │ + └─→ RPPBuilder._build_clip() + ├─ audio: emit D_VOL if vol_mult ≠ 1.0 + └─ MIDI: notes already velocity-scaled +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `src/core/schema.py` | Modify | Add `vol_mult: float = 1.0` to ClipDef | +| `scripts/compose.py` | Modify | Add TRACK_ACTIVITY dict, `_section_active()` helper, set multipliers in `build_section_structure()`, rename build→pre-chorus, refactor 7 builders | +| `src/reaper_builder/__init__.py` | Modify | `_build_clip()` emits D_VOL for audio clips with vol_mult≠1.0 | +| `tests/test_section_builder.py` | Modify | Add tests for multiplier population per section type | +| `tests/test_compose_integration.py` | Modify | Update section-aware tests | +| `tests/test_reaper_builder.py` | Modify | Add D_VOL emission tests | + +## Interfaces / Contracts + +```python +# New: TRACK_ACTIVITY dict in compose.py +TRACK_ACTIVITY: dict[str, dict[str, bool]] = { + "intro": {"drumloop": True, "perc": False, "bass": False, ...}, + "verse": {"drumloop": True, "perc": True, "bass": True, ...}, + "pre-chorus": {...}, + "chorus": {...}, # all True + "bridge": {"drumloop": True, "chords": True, "pad": True, ...}, + "final": {"drumloop": True, "bass": True, "chords": True, "lead": True, "pad": True}, + "outro": {}, # all False +} + +# New helper +def _section_active(section: SectionDef, role: str, activity: dict) -> bool: + return activity.get(section.name, {}).get(role, False) + +# Modified: build_section_structure() sets multipliers +SECTION_MULTIPLIERS = { + "intro": (0.6, 0.70), + "verse": (0.7, 0.85), + "pre-chorus": (0.85, 0.95), + "chorus": (1.0, 1.00), + "bridge": (0.6, 0.75), + "final": (1.0, 1.00), + "outro": (0.4, 0.60), +} + +# Modified: ClipDef gains vol_mult +@dataclass +class ClipDef: + ... + vol_mult: float = 1.0 +``` + +## Testing Strategy + +| Layer | What to Test | Approach | +|-------|-------------|----------| +| Unit | SectionDef multiplier population | `test_section_builder.py` — verify velocity_mult/vol_mult by section type | +| Unit | `_section_active()` helper | Edge cases: unknown section, unknown role, all known sections | +| Unit | ClipDef.vol_mult default | `test_core_schema.py` — default is 1.0 | +| Integration | D_VOL in RPP output | `test_reaper_builder.py` — audio clip with vol_mult≠1.0 emits D_VOL, default vol_mult=1.0 emits none | +| Integration | Builders respect activity | `test_compose_integration.py` — intro has no bass/chords/lead, chorus has all | +| Integration | Section rename | Grep all `.py` for "build" section name, CI runs full suite (110 tests) | + +## Migration / Rollout + +No migration required. `vol_mult` defaults to 1.0 (no behavioral change). Section rename is cosmetic. Revert commit to undo. + +## Open Questions + +None. diff --git a/.sdd/changes/section-energy/proposal.md b/.sdd/changes/section-energy/proposal.md new file mode 100644 index 0000000..c5fb9b2 --- /dev/null +++ b/.sdd/changes/section-energy/proposal.md @@ -0,0 +1,90 @@ +# Proposal: Section Energy Curve + +## Intent + +All 9 arrangement sections sound identical — full-band at static volume. Professional reggaeton builds energy across sections via sparse-to-dense track layering, velocity variation, and section-level volume riding. This change adds the missing dynamics. + +## Scope + +### In Scope +- Centralized `TRACK_ACTIVITY` dict: which track roles play in which sections +- `build_section_structure()` sets `velocity_mult` and `vol_mult` per section type +- Unified `_section_active()` helper — single source of truth for section activity +- All 7 track builders refactored to check centralized activity + apply `velocity_mult` +- RPPBuilder extended to apply per-clip `vol_mult` (audio items get `D_VOL`, MIDI items get velocity scaling) +- Rename `build` section to `pre-chorus` (professional reggaeton convention) +- Update integration tests to match new section behavior + +### Out of Scope +- Volume automation envelopes (REAPER `VOLENV2`) — deferred +- Transition FX generation (risers, impacts, filtered sweeps) +- Per-section filter automation (AutoFilter cutoff sweeps) +- Section scene names in REAPER project — still flat arrangement + +## Capabilities + +### Modified Capabilities +- `section-structure`: SectionDef `velocity_mult` and `vol_mult` now populated per section type instead of defaulting to 1.0 +- `track-generation`: All builders consume centralized activity matrix + section multipliers instead of ad-hoc section name checks + +### New Capabilities +- `section-activity`: Centralized activity matrix defining which track roles are active per section type +- `clip-volume`: ClipDef receives optional `vol_mult` field; RPPBuilder applies it to item `D_VOL` (audio) or velocity scaling (MIDI) + +## Approach + +**Principle**: Schema fields (`velocity_mult`, `vol_mult`) already exist in `SectionDef`. The bug is they're never populated or consumed. Add the wiring. + +1. **Activity matrix** — `TRACK_ACTIVITY` dict in compose.py maps `section_type → {role: bool}`. Section types: `intro`, `verse`, `pre-chorus`, `chorus`, `bridge`, `final`, `outro`. + +2. **Section multipliers** — `build_section_structure()` sets `velocity_mult` (controls note velocity) and `vol_mult` (controls clip gain) based on section type: + | Section | velocity_mult | vol_mult | + |---------|--------------|----------| + | intro | 0.6 | 0.70 | + | verse | 0.7 | 0.85 | + | pre-chorus | 0.85 | 0.95 | + | chorus | 1.0 | 1.00 | + | bridge | 0.6 | 0.75 | + | final | 1.0 | 1.00 | + | outro | 0.4 | 0.60 | + +3. **Builder refactor** — Replace ad-hoc `if section.name in ("chorus","final")` with `_section_active(section, role, activity)` check. Multiply MIDI velocities by `section.velocity_mult`. + +4. **RPPBuilder** — `_build_item()` adds `D_VOL` for audio clips when `clip.vol_mult != 1.0`. MIDI clips already get velocity-scaled notes from step 3. + +5. **Section rename** — `build` → `pre-chorus` in `SECTIONS` and all references (`DRUMLOOP_ASSIGNMENTS`, builder filters). Existing section name "build" only appears in compose.py SECTIONS — no external consumers. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `scripts/compose.py` | Modified | Add `TRACK_ACTIVITY`, `_section_active()`, update `build_section_structure()`, refactor all 7 builders, rename build→pre-chorus | +| `src/core/schema.py` | Modified | Add `vol_mult` field to `ClipDef` (optional, default 1.0) | +| `src/reaper_builder/__init__.py` | Modified | `_build_item()` applies `D_VOL` from `clip.vol_mult` | +| `tests/test_compose_integration.py` | Modified | Update section name references (build→pre-chorus), add activity matrix tests | +| `tests/test_section_builder.py` | Modified | Add `velocity_mult`/`vol_mult` population tests | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| RPP `D_VOL` not recognized by REAPER | Low | REAPER .rpp spec documents D_VOL on ITEM; test with actual REAPER load | +| Section rename breaks test fixtures | Med | Grep all `.py` for "build" section name; CI catches breakage | +| Activity matrix too strict — creative users want full band in bridge | Low | Activity matrix is a constant at file top — easy to edit; could be CLI flag later | + +## Rollback Plan + +Revert commit. No schema migrations needed — `vol_mult` on ClipDef defaults to 1.0 (zero behavioral change if not set). Section rename is cosmetic in output RPP. + +## Dependencies + +None — no new packages, no external APIs. + +## Success Criteria + +- [ ] All sections sound audibly different (sparse intro → dense chorus) +- [ ] Drums + pad only in intro (no bass, no lead, no chords) +- [ ] Full band in chorus (all 7 tracks active) +- [ ] Velocity differences between verse (soft) and chorus (hard) +- [ ] 110 existing tests still pass +- [ ] `.rpp` output opens in REAPER without errors diff --git a/.sdd/changes/section-energy/spec.md b/.sdd/changes/section-energy/spec.md new file mode 100644 index 0000000..48e8d5c --- /dev/null +++ b/.sdd/changes/section-energy/spec.md @@ -0,0 +1,134 @@ +# Delta Specs: Section Energy Curve + +## ADDED Requirements — section-activity + +### Requirement: Centralized Activity Matrix + +The system MUST provide a `TRACK_ACTIVITY` dict mapping `section_type → {role: bool}` as the single source of truth for which track roles play in each section. Section types: `intro`, `verse`, `pre-chorus`, `chorus`, `bridge`, `final`, `outro`. Roles: `drumloop`, `perc`, `bass`, `chords`, `lead`, `clap`, `pad`. + +| Section | drumloop | perc | bass | chords | lead | clap | pad | +|---------|----------|------|------|--------|------|------|-----| +| intro | true | - | - | - | - | - | - | +| verse | true | true | true | true | - | - | - | +| pre-chorus | true | true | true | true | - | - | true | +| chorus | true | true | true | true | true | true | true | +| bridge | true | - | - | true | - | - | true | +| final | true | - | true | true | true | - | true | +| outro | - | - | - | - | - | - | - | + +#### Scenario: Intro is sparse + +- GIVEN section_type=`intro` +- WHEN `_section_active("intro", "bass", activity)` is called +- THEN it returns `False` +- AND only `drumloop` returns `True` + +#### Scenario: Chorus is full band + +- GIVEN section_type=`chorus` +- WHEN `_section_active("chorus", "lead", activity)` is called +- THEN it returns `True` +- AND all 7 roles return `True` + +### Requirement: Section Activity Helper + +The system MUST provide `_section_active(section: SectionDef, role: str, activity: dict) -> bool` that returns whether a role is active, defaulting to `False` for unknown section/role. + +#### Scenario: Unknown section returns False + +- GIVEN section_type=`xyz` not in TRACK_ACTIVITY +- WHEN `_section_active(section, "bass", matrix)` is called +- THEN it returns `False` + +--- + +## ADDED Requirements — clip-volume + +### Requirement: ClipDef Volume Multiplier + +`ClipDef` MUST have an optional `vol_mult` field (float, default 1.0). When `vol_mult != 1.0`, the RPP builder SHALL apply it: +- Audio clips: emit `D_VOL` attribute on ITEM +- MIDI clips: scale all `MidiNote.velocity` by `vol_mult` + +#### Scenario: Audio clip with vol_mult emits D_VOL + +- GIVEN ClipDef(audio_path="kick.wav", vol_mult=0.7) +- WHEN RPPBuilder writes the ITEM +- THEN the ITEM includes `D_VOL 0.7` + +#### Scenario: MIDI clip with vol_mult scales velocity + +- GIVEN ClipDef(midi_notes=[MidiNote(velocity=80)], vol_mult=0.5) +- WHEN clip is processed by RPPBuilder +- THEN emitted velocity is 40 + +### Requirement: RPPBuilder D_VOL Emission + +`_build_clip()` MUST append `["D_VOL", str(clip.vol_mult)]` to the ITEM element when `clip.vol_mult != 1.0` and the clip is audio. + +#### Scenario: Default vol_mult=1.0 emits no D_VOL + +- GIVEN ClipDef(audio_path="loop.wav") with default vol_mult=1.0 +- WHEN RPPBuilder writes the ITEM +- THEN no `D_VOL` line is emitted + +--- + +## MODIFIED Requirements — section-structure + +### Requirement: SectionDef Multipliers Per Section Type + +`build_section_structure()` MUST populate `SectionDef.velocity_mult` and `vol_mult` based on section type, not default to 1.0. Multipliers SHALL follow this table: + +| Section | velocity_mult | vol_mult | +|---------|--------------|----------| +| intro | 0.6 | 0.70 | +| verse | 0.7 | 0.85 | +| pre-chorus | 0.85 | 0.95 | +| chorus | 1.0 | 1.00 | +| bridge | 0.6 | 0.75 | +| final | 1.0 | 1.00 | +| outro | 0.4 | 0.60 | + +(Previously: velocity_mult and vol_mult always defaulted to 1.0) + +#### Scenario: Intro has low velocity and volume + +- GIVEN `build_section_structure()` is called +- WHEN the intro section is created +- THEN `velocity_mult=0.6` and `vol_mult=0.70` + +#### Scenario: Chorus has full velocity and volume + +- GIVEN `build_section_structure()` is called +- WHEN the chorus section is created +- THEN `velocity_mult=1.0` and `vol_mult=1.0` + +--- + +## MODIFIED Requirements — track-generation + +### Requirement: Builders Use Centralized Activity + Section Multipliers + +All 7 track builders MUST replace ad-hoc section name checks with calls to `_section_active()`. All builders MUST multiply MIDI velocities by `section.velocity_mult`. The `build` section SHALL be renamed to `pre-chorus` in `SECTIONS` and all references. + +(Previously: builders used inline `if section.name in (...)` checks and `section.energy` for velocity; section was named `build`) + +#### Scenario: Chords not generated in intro + +- GIVEN `build_chords_track()` with sections including intro +- WHEN processing the intro section +- THEN `_section_active("intro", "chords", ...)` returns `False` +- AND no clip is created for that section + +#### Scenario: Bass velocity scaled by section multiplier + +- GIVEN `build_bass_track()` with a verse section (velocity_mult=0.7) +- WHEN MIDI notes are created +- THEN each note velocity is multiplied by 0.7 + +#### Scenario: Section rename reflects in output + +- GIVEN SECTIONS tuple has `("pre-chorus", 4, 0.7, False)` +- WHEN `build_section_structure()` is called +- THEN the section is named `pre-chorus` not `build` diff --git a/.sdd/changes/section-energy/tasks.md b/.sdd/changes/section-energy/tasks.md new file mode 100644 index 0000000..9c115f6 --- /dev/null +++ b/.sdd/changes/section-energy/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Section Energy Curve + +## Phase 1: Schema + Foundation + +- [x] 1.1 Add `vol_mult: float = 1.0` field to `ClipDef` in `src/core/schema.py` +- [x] 1.2 Add `TRACK_ACTIVITY` dict and `_section_active()` helper to `scripts/compose.py` +- [x] 1.3 Add `SECTION_MULTIPLIERS` dict and update `build_section_structure()` to set `velocity_mult` and `vol_mult` per section type +- [x] 1.4 Rename `build` → `pre-chorus` in `SECTIONS` and `DRUMLOOP_ASSIGNMENTS` in `scripts/compose.py` + +## Phase 2: Builder Refactor + +- [x] 2.1 Refactor `build_drumloop_track()` — use `_section_active()` instead of `DRUMLOOP_ASSIGNMENTS` dict lookup +- [x] 2.2 Refactor `build_perc_track()` — replace `if section.name in (...)` with `_section_active()` +- [x] 2.3 Refactor `build_bass_track()` — replace `section.energy` with `section.velocity_mult` for velocity calc +- [x] 2.4 Refactor `build_chords_track()` — use `_section_active()` for section check, `velocity_mult` for velocity +- [x] 2.5 Refactor `build_lead_track()` — use `_section_active()` for section check, `velocity_mult` for velocity +- [x] 2.6 Refactor `build_clap_track()` — use `_section_active()` instead of `section.name.startswith(...)` +- [x] 2.7 Refactor `build_pad_track()` — use `_section_active()` for section check, `velocity_mult` for velocity + +## Phase 3: RPPBuilder D_VOL Emission + +- [x] 3.1 Update `_build_clip()` in `src/reaper_builder/__init__.py` to emit `D_VOL` when `clip.vol_mult != 1.0` and clip is audio +- [x] 3.2 Update `_build_midi_source()` to scale notes by `clip.vol_mult` (post-processing fallback) + +## Phase 4: Tests + +- [x] 4.1 Add `test_build_section_structure_sets_multipliers` to `tests/test_section_builder.py` — verify per-section velocity_mult/vol_mult +- [x] 4.2 Add `test_section_active_helper` — edge cases: unknown section, unknown role, all known combos +- [x] 4.3 Add `test_clipdef_vol_mult_default` to `tests/test_core_schema.py` +- [x] 4.4 Add `test_dvol_emission` to `tests/test_reaper_builder.py` — audio clip vol_mult≠1.0 emits D_VOL, default vol_mult=1.0 does not +- [x] 4.5 Update `test_compose_integration.py` — verify sparse intro (no bass/chords/lead) vs dense chorus, section rename +- [x] 4.6 Run full test suite (167 tests) — 161 pass, 6 pre-existing failures in test_chords.py (unrelated) diff --git a/.sdd/changes/sidechain/design.md b/.sdd/changes/sidechain/design.md new file mode 100644 index 0000000..760d204 --- /dev/null +++ b/.sdd/changes/sidechain/design.md @@ -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. diff --git a/.sdd/changes/sidechain/proposal.md b/.sdd/changes/sidechain/proposal.md new file mode 100644 index 0000000..6fb69c5 --- /dev/null +++ b/.sdd/changes/sidechain/proposal.md @@ -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 diff --git a/.sdd/changes/sidechain/spec.md b/.sdd/changes/sidechain/spec.md new file mode 100644 index 0000000..8437b70 --- /dev/null +++ b/.sdd/changes/sidechain/spec.md @@ -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 diff --git a/.sdd/changes/sidechain/tasks.md b/.sdd/changes/sidechain/tasks.md new file mode 100644 index 0000000..1921914 --- /dev/null +++ b/.sdd/changes/sidechain/tasks.md @@ -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 diff --git a/.sdd/changes/smart-chords/design.md b/.sdd/changes/smart-chords/design.md new file mode 100644 index 0000000..fc4971f --- /dev/null +++ b/.sdd/changes/smart-chords/design.md @@ -0,0 +1,98 @@ +# Design: Smart Chord Engine + +## Technical Approach + +New `ChordEngine` class in `src/composer/chords.py`. Pure Python, seed-based `random.Random`, using existing `CHORD_TYPES` and `NOTE_NAMES` from `composer/__init__.py`. Voice leading: greedy scoring of candidate voicings. `build_chords_track()` imports and delegates. + +## Architecture Decisions + +| Decision | Choice | Rejected | Rationale | +|----------|--------|----------|-----------| +| RNG strategy | `random.Random(seed)` instance | Global `random.seed()` | Isolates ChordEngine from other modules; no side effects | +| Voice scoring | Greedy min-semi distance per chord | Global optimization (DP) | Simple, fast, produces musical results for ≤12 chords; DP overkill | +| Inversion encoding | `dict[str, int]` → `{"root":0, "first":1, "second":2}` | Enum class | Follows existing dict-based config pattern (`CHORD_TYPES`) | +| Emotion mapping | Hardcoded `dict[str, list[int]]` degree offsets | Data file | 4 modes, 7 entries each — file indirection adds complexity for no benefit | +| Chord output format | `list[list[int]]` (list of MIDI note lists) | Dict with metadata | Directly feedable to existing `MidiNote` factory; no schema change | + +## Data Flow + +``` +User: --emotion dark --seed 42 + │ + ▼ +build_chords_track() → ChordEngine("Am", seed=42) + │ + ├── progression(8, emotion="dark", bpc=4, inversion="root") + │ │ + │ ├── EMOTION_PROGRESSIONS["dark"] → [0, 5, 10, 7] + │ ├── get_chord_degrees(root, scale, degrees) → [chords] + │ ├── voice_leading(chords, "root") → [voicings] + │ └── apply_inversion(voicings, "root") → list[list[int]] + │ + ▼ + MidiNote list → ClipDef → TrackDef +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `src/composer/chords.py` | Create | `ChordEngine` class + `EMOTION_PROGRESSIONS` | +| `scripts/compose.py` | Modify | `build_chords_track()` imports + delegates to `ChordEngine` | +| `tests/test_chords.py` | Create | Unit tests for R1-R4, integration for R7 | + +## Interfaces + +```python +# src/composer/chords.py +class ChordEngine: + def __init__(self, key: str, seed: int = 42): ... + def progression( + self, bars: int, emotion: str = "classic", + beats_per_chord: int = 4, inversion: str = "root" + ) -> list[list[int]]: ... + + # Internal + def _get_degrees(self, emotion: str) -> list[int]: ... + def _voice_leading(self, chords: list[list[int]], inversion: str) -> list[list[int]]: ... + def _score_voicing(self, prev: list[int], cand: list[int]) -> int: ... + def _apply_inversion(self, voicing: list[int], inversion: str) -> list[int]: ... +``` + +```python +# EMOTION_PROGRESSIONS — degree offsets (semitone from root) per emotion +# Pattern: [(degree, quality), ...] +EMOTION_PROGRESSIONS = { + "romantic": [(0, "min"), (8, "maj"), (4, "maj"), (10, "maj")], # i-VI-III-VII + "dark": [(0, "min"), (5, "min"), (10, "maj"), (7, "min")], # i-iv-V-v + "club": [(0, "min"), (10, "maj"), (8, "maj"), (4, "maj")], # i-VII-VI-III + "classic": [(0, "min"), (8, "maj"), (4, "maj"), (10, "maj")], # i-VI-III-VII +} +``` + +## Voice Leading Algorithm + +``` +For position i (0..n-1): + 1. Build all voicings of chord[i] (root + inversions → candidate lists) + 2. If i > 0: for each candidate, score = sum(abs(c[j] - prev[j])) across voices + 3. Filter candidates where score ≤ 4 per voice + 4. Select lowest-total-score candidate (greedy) + 5. If no candidate passes filter: keep raw chord (no voicing penalty) +``` + +Returns minimum-movement path through chord sequence. + +## Testing Strategy + +| Layer | What | Approach | +|-------|------|----------| +| Unit | Determinism (R1) | `ChordEngine(seed=42).progression(8)` × 3 calls — assert equality | +| Unit | Voice leading ≤4 (R2) | Run progression, verify all adjacent pairs | +| Unit | Inversions (R3) | Assert bass note = target (root/3rd/5th) | +| Unit | Emotion divergence (R4) | 4 emotions → assert 4 distinct outputs | +| Integration | CLI --emotion flag (R7) | `compose.py --emotion dark` → verify ChordEngine called | + +## Open Questions + +- [ ] Should `--emotion` be a CLI flag or auto-detected from section type? Per proposal, explicit flag. diff --git a/.sdd/changes/smart-chords/proposal.md b/.sdd/changes/smart-chords/proposal.md new file mode 100644 index 0000000..761fea4 --- /dev/null +++ b/.sdd/changes/smart-chords/proposal.md @@ -0,0 +1,75 @@ +# Proposal: Smart Chord Engine + +## Intent + +Current chord generation (`build_chords_track`) produces static root-position block chords with zero voice leading — every chord jump resets all 3 voices, producing audible jumps and amateur-sounding progressions. Add a `ChordEngine` class with voice leading, inversion selection, emotion modes, and genre-specific reggaeton progressions. + +## Scope + +### In Scope +- New `src/composer/chords.py` with `ChordEngine` class +- Voice leading: minimize semitone movement, max 4 semitone jump per voice +- Inversion selection: root, first, second inversion +- 4 emotion modes: romantic, dark, club, classic +- Genre-specific reggaeton chord progressions per emotion +- Deterministic: seed-based reproducibility +- Modify `build_chords_track()` in `scripts/compose.py` to use `ChordEngine` + +### Out of Scope +- Seventh/suspended/diminished chord types (use existing `CHORD_TYPES`) +- Real-time chord generation (only batch/offline) +- Other genres beyond reggaeton +- Chord rhythm/pattern generation (only chord selection + voicing) + +## Capabilities + +### New Capabilities +- `chord-engine`: `ChordEngine` class with seed-based deterministic progression generation, voice leading, and inversion selection + +### Modified Capabilities +- `chords-track-generation`: `build_chords_track()` delegates to `ChordEngine` instead of hardcoded i-VI-III-VII + +## Approach + +**Pure Python, zero new dependencies** — all chord logic runs on MIDI note numbers using existing `NOTE_NAMES`, `SCALE_INTERVALS`, and `CHORD_TYPES` from `composer/__init__.py`. + +Voice leading: score candidate voicings by total semitone distance from previous chord; select lowest-score candidate within the 4-semitone max-jump constraint. + +Emotions → progression profiles: +| Emotion | Degrees | Quality flavor | +|----------|---------|----------------| +| romantic | i-VI-III-VII | softer, wider voicings | +| dark | i-iv-V-v | minor-focused | +| club | i-VII-VI-V | driving, ascending | +| classic | i-VI-III-VII | tight block chords | + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `src/composer/chords.py` | New | `ChordEngine` class | +| `scripts/compose.py` | Modify | `build_chords_track()` uses `ChordEngine` | +| `tests/test_chords.py` | New | Unit tests for voice leading, emotion modes, inversions | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Voice leading sounds worse than static | Low | 4-semitone cap prevents unnatural jumps; inversions smooth transitions | +| Emotion modes too similar | Med | Each has distinct degree set and quality bias | + +## Rollback Plan + +Revert `build_chords_track()` to hardcoded progression. Delete `src/composer/chords.py`. One commit. + +## Dependencies + +None. Uses existing `composer/__init__.py` constants only. + +## Success Criteria + +- [ ] `ChordEngine(seed=42).progression(8)` returns identical output on repeated calls +- [ ] No voice leap exceeds 4 semitones +- [ ] All 4 emotion modes produce distinct chord sequences +- [ ] `build_chords_track()` produces MIDI notes with `<4 semitone jumps between consecutive chords` +- [ ] Existing tests pass unchanged diff --git a/.sdd/changes/smart-chords/spec.md b/.sdd/changes/smart-chords/spec.md new file mode 100644 index 0000000..2853438 --- /dev/null +++ b/.sdd/changes/smart-chords/spec.md @@ -0,0 +1,47 @@ +# Chords Specification + +## Purpose + +Chord progression generation with voice leading, inversion selection, and emotion-aware patterns for reggaeton. Deterministic and testable. + +## Requirements + +| # | Requirement | Strength | Key Scenarios | +|---|------------|----------|---------------| +| R1 | `ChordEngine(key, seed)` MUST produce identical progressions for same seed+key | MUST | Same seed → same notes; different seed → different notes | +| R2 | Voice leading MUST minimize semitone movement between consecutive chords, capping at 4 semitones per voice | MUST | 2-chord transition ≤4 semitones per voice; 8-bar progression all leaps ≤4 | +| R3 | SHALL support 3 inversion modes: `root`, `first`, `second` | SHALL | Root: lowest note = root; First: lowest = third; Second: lowest = fifth | +| R4 | MUST support 4 emotion modes: `romantic`, `dark`, `club`, `classic` | MUST | Each emotion yields distinct degree sequence; unknown emotion → `classic` fallback | +| R5 | `progression(bars, emotion, beats_per_chord, inversion)` SHALL return `list[list[int]]` — ordered chord voicings as MIDI note lists | SHALL | 8 bars @ 4 BpC → 8 chords; empty list for 0 bars | +| R6 | Reggaeton progressions SHOULD use genre-appropriate cadences per emotion | SHOULD | Romantic: i-VI-III-VII; Dark: i-iv-V-v; Club: i-VII-VI-V; Classic: i-VI-III-VII | +| R7 | `build_chords_track()` SHALL delegate to `ChordEngine` instead of hardcoded progression | SHALL | CLI `--emotion dark` → dark progression in output | + +### Scenario: Deterministic reproducibility + +- GIVEN `ChordEngine("Am", seed=42)` +- WHEN `progression(bars=8)` called twice +- THEN both calls return identical `list[list[int]]` + +### Scenario: Voice leading within bounds + +- GIVEN any 2 consecutive chords from a progression +- WHEN computing voice leading +- THEN no voice moves more than 4 semitones from its previous position + +### Scenario: Emotion modes diverge + +- GIVEN `ChordEngine("Am", seed=0)` with emotions `romantic`, `dark`, `club`, `classic` +- WHEN `progression(8)` called per emotion +- THEN all 4 output sequences differ + +### Scenario: Invalid emotion falls back + +- GIVEN `ChordEngine("Am")` with emotion `"angry"` (unknown) +- WHEN `progression(8)` called +- THEN defaults to `classic` progression, no error raised + +### Scenario: Integration with compose.py + +- GIVEN `python scripts/compose.py --key Am --emotion dark --output test.rpp` +- WHEN build completes +- THEN Chords track contains voicings matching dark-emotion progression diff --git a/.sdd/changes/smart-chords/tasks.md b/.sdd/changes/smart-chords/tasks.md new file mode 100644 index 0000000..ceaad91 --- /dev/null +++ b/.sdd/changes/smart-chords/tasks.md @@ -0,0 +1,27 @@ +# Tasks: Smart Chord Engine + +## Phase 1: Foundation + +- [x] 1.1 Create `src/composer/chords.py` with `EMOTION_PROGRESSIONS` dict and `ChordEngine.__init__(key, seed)` +- [x] 1.2 Implement `ChordEngine._get_degrees(emotion)` — resolve emotion → degree/quality list with `classic` fallback +- [x] 1.3 Implement `ChordEngine._apply_inversion(voicing, inversion)` — reorder notes so target is lowest (root=0, first=1, second=2) + +## Phase 2: Core + +- [x] 2.1 Implement `ChordEngine._score_voicing(prev, cand)` — sum abs semitone diff per voice pair +- [x] 2.2 Implement `ChordEngine._voice_leading(chords, inversion)` — greedy min-score path, cap 4 semitones/voice +- [x] 2.3 Implement `ChordEngine.progression(bars, emotion, bpc, inversion)` — full pipeline: degrees → chords → voice leading → output + +## Phase 3: Integration + +- [x] 3.1 Modify `build_chords_track()` in `scripts/compose.py` to import + instantiate `ChordEngine`, delegate chord generation +- [x] 3.2 Add `--emotion` and `--inversion` CLI flags to `scripts/compose.py` (default: `romantic`, `root`) +- [x] 3.3 Wire section energy (`vm`) from existing section loop into note velocity scaling + +## Phase 4: Testing + +- [x] 4.1 Create `tests/test_chords.py` — unit test determinism: same seed → same output (R1) +- [x] 4.2 Test voice leading: assert max semitone diff ≤ 4 across all adjacent chord pairs (R2) +- [x] 4.3 Test inversions: assert bass note matches root/third/fifth (R3) +- [x] 4.4 Test emotion divergence: all 4 emotions produce distinct progressions (R4) +- [x] 4.5 Integration: `compose.py --emotion dark --output test.rpp` produces chords track using dark progression (R7) diff --git a/.sdd/changes/transitions-fx/design.md b/.sdd/changes/transitions-fx/design.md new file mode 100644 index 0000000..2af036c --- /dev/null +++ b/.sdd/changes/transitions-fx/design.md @@ -0,0 +1,88 @@ +# Design: Transitions FX + +## Technical Approach + +Add `build_fx_track()` to `scripts/compose.py` that places audio FX clips from the sample library at 7 section boundaries. Uses `SampleSelector.select_one(role="fx")` with per-type character hints. Reuses `ClipDef.fade_in/out`. New track inserted after Clap, before Pad — after main tracks, before sends are wired. + +## Architecture Decisions + +| Decision | Choice | Rejected | Rationale | +|----------|--------|----------|-----------| +| One FX track vs per-section | Single dedicated track | Per-section tracks | Simpler; one import per sample in REAPER; manageable clip count (7–9) | +| Sample selection | Weighted random top-5 | Pinned specific files | Variety across runs; selector scoring already works | +| Boundary timing | Hardcoded beat-offset map | Audio analysis | Section structure is deterministic; bar counts are fixed | +| Riser+impact at chorus | Two clips, same boundary | Single combined clip | Requires different timing; riser before boundary, impact on it | + +## Data Flow + +``` +SECTIONS → offsets (bar → beat) + │ + ▼ +FX_TRANSITIONS map: {boundary_idx: (type, start_offset, length, fade_in, fade_out)} + │ + ▼ +build_fx_track(sections, offsets, selector, seed) + ├── for each entry in FX_TRANSITIONS: + │ ├── boundary_beat = offsets[boundary_idx] * 4 + │ ├── position = boundary_beat + start_offset + │ ├── sample = selector.select_one(role="fx", seed=seed+idx) + │ └── ClipDef(position, length, audio_path, fade_in, fade_out) + │ + ▼ +TrackDef("Transition FX", volume=0.72, clips=[...], send_level={...}) +``` + +## Boundary → FX Map + +| # | Boundary | Beat | FX Type | Position | Length | Fade In | Fade Out | +|---|----------|------|---------|----------|--------|---------|----------| +| 2 | verse→build | 48 | sweep | 46 | 2 | 0.3 | 0.0 | +| 3 | build→chorus | 64 | **riser** | 60 | 4 | 1.5 | 0.0 | +| 3 | build→chorus | 64 | **impact** | 64 | 2 | 0.0 | 0.3 | +| 4 | chorus→verse2 | 96 | transition | 94 | 2 | 0.2 | 0.2 | +| 5 | verse2→chorus2 | 128 | riser | 124 | 4 | 1.0 | 0.0 | +| 6 | chorus2→bridge | 160 | sweep | 158 | 2 | 0.2 | 0.2 | +| 7 | bridge→final | 176 | riser | 172 | 4 | 1.0 | 0.0 | +| 8 | final→outro | 208 | sweep | 206 | 2 | 0.3 | 0.5 | + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `scripts/compose.py` | Modify | Add `FX_TRANSITIONS` dict + `build_fx_track()` (~50 lines); call in `main()` after clap track, before return tracks | + +## Key Implementation Detail + +`SampleSelector.select_one()` has a `seed` kwarg — new in the selector API. If not yet supported, use `select(role="fx", limit=5)` with manual `random.choice()`. Since FX is in `ATONAL_ROLES`, key compatibility scoring is skipped (neutral 0.5). + +## Track Ordering + +``` +tracks = [ + build_drumloop_track(...), # 0 + build_perc_track(...), # 1 + build_bass_track(...), # 2 + build_chords_track(...), # 3 + build_lead_track(...), # 4 + build_clap_track(...), # 5 + build_fx_track(...), # 6 ← NEW + build_pad_track(...), # 7 +] +return_tracks = create_return_tracks() # 8 (Reverb), 9 (Delay) +``` + +Send wiring applies to all non-return tracks automatically via existing loop. FX track sends: Reverb=0.08, Delay=0.05. + +## Testing Strategy + +| Layer | What | How | +|-------|------|-----| +| Unit | `build_fx_track` returns TrackDef with 8 clips | Mock selector via `SampleSelector.__init__` patching | +| Unit | Clip positions match boundary map | Assert `clip.position` values equal expected beats | +| Integration | End-to-end .rpp output | `compose.py --bpm 99 --key Am --output test.rpp`; grep for "Transition FX" ` 0 + +### Requirement: FX Sample Selection + +The system SHALL select FX samples via `SampleSelector.select_one(role="fx")`, favoring short samples for impacts, long for risers. + +#### Scenario: FX role returns candidates + +- GIVEN 57 FX samples in library with ATONAL_ROLES including "fx" +- WHEN `select(role="fx")` is called +- THEN non-empty result returned; key scoring skipped (neutral 0.5) + +### Requirement: Fade Curves + +FX clips MUST use `fade_in`/`fade_out`. Risers: `fade_in` ≥ 0.3s. Impacts: `fade_out` ≥ 0.2s. + +#### Scenario: Riser fades in, impact fades out + +- GIVEN riser and impact clips defined +- WHEN ClipDef is created +- THEN riser.fade_in > 0 AND impact.fade_out > 0 + +### Requirement: FX Track Mixing + +The FX track SHALL have volume ≤ 0.80 and send to Reverb/Delay returns. + +#### Scenario: FX track has moderate volume and sends + +- GIVEN "Transition FX" track created +- WHEN track is defined +- THEN volume = 0.72, send_level includes reverb (0.08) and delay (0.05) diff --git a/.sdd/changes/transitions-fx/tasks.md b/.sdd/changes/transitions-fx/tasks.md new file mode 100644 index 0000000..50cad48 --- /dev/null +++ b/.sdd/changes/transitions-fx/tasks.md @@ -0,0 +1,27 @@ +# Tasks: Transitions FX + +## Phase 1: FX Transition Map + +- [x] 1.1 Add `FX_TRANSITIONS` dict to `scripts/compose.py`: `{boundary_index: (type, start_offset, length, fade_in, fade_out)}` with 8 entries matching design boundary map +- [x] 1.2 Add `FX_ROLE = "fx"` constant referencing ATONAL_ROLES membership + +## Phase 2: Build FX Track + +- [x] 2.1 Implement `build_fx_track(sections, offsets, selector, seed=0)` — iterates `FX_TRANSITIONS`, computes clip positions from offsets, selects FX samples +- [x] 2.2 For each boundary: call `selector.select_one(role="fx", seed=seed + idx)` to pick sample +- [x] 2.3 Create `ClipDef(position, length, name, audio_path, fade_in, fade_out)` per boundary +- [x] 2.4 Build `TrackDef("Transition FX", volume=0.72, clips=[...], send_level={reverb: 0.08, delay: 0.05})` +- [x] 2.5 Add docstring explaining boundary map and FX types (riser/impact/sweep/transition) + +## Phase 3: Integration + +- [x] 3.1 Call `build_fx_track()` in `main()` after clap track, before pad track +- [x] 3.2 Verify send wiring loop handles new track (existing code; confirm no regression) + +## Phase 4: Testing & Verification + +- [x] 4.1 Write unit test: `build_fx_track` returns TrackDef with exactly 8 clips +- [x] 4.2 Write unit test: clip positions and fade values match design's boundary map +- [x] 4.3 Write unit test: all clips have `audio_path` set (not None) +- [x] 4.4 Write integration test: `compose.py --bpm 99 --key Am --output /tmp/test.rpp` produces valid .rpp with "Transition FX" track +- [x] 4.5 Run full `pytest` suite — all 110 existing tests pass diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dd4b38e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Code Review Rules + +## Python +- Type hints on all function signatures +- Dataclasses over dicts for structured data +- Deterministic output: seed-based RNG, no global random state +- No bare except clauses + +## Architecture +- Separate modules by concern (calibrator, composer, builder, selector, validator) +- Post-processing over inline modification (Calibrator.apply() pattern) +- Schema changes must be backward-compatible (new fields get defaults) + +## Testing +- pytest only +- Unit tests for all new functions +- Integration tests for end-to-end flows +- Regression: existing tests must not break + +## SDD +- All changes follow SDD pipeline: propose → spec → design → tasks → apply → verify +- Artifacts stored in .sdd/changes// diff --git a/output/analysis_test.rpp b/output/analysis_test.rpp new file mode 100644 index 0000000..07f377b --- /dev/null +++ b/output/analysis_test.rpp @@ -0,0 +1,2488 @@ + + + RIPPLE 0 0 + GROUPOVERRIDE 0 0 0 0 + AUTOXFADE 129 + ENVATTACH 3 + POOLEDENVATTACH 0 + TCPUIFLAGS 0 + MIXERUIFLAGS 11 48 + ENVFADESZ10 40 + PEAKGAIN 1 + FEEDBACK 0 + PANLAW 1 + PROJOFFS 0 0 0 + MAXPROJLEN 0 0 + GRID 3199 8 1 8 1 0 0 0 + TIMEMODE 1 5 -1 30 0 0 -1 0 + VIDEO_CONFIG 0 0 65792 + PANMODE 3 + PANLAWFLAGS 3 + CURSOR 0 + ZOOM 100 0 0 + VZOOMEX 6 0 + USE_REC_CFG 0 + RECMODE 1 + SMPTESYNC 0 30 100 40 1000 300 0 0 1 0 0 + LOOP 0 + LOOPGRAN 0 4 + RECORD_PATH Media "" + + + + + RENDER_FILE "" + RENDER_PATTERN "" + RENDER_FMT 0 2 0 + RENDER_1X 0 + RENDER_RANGE 1 0 0 0 1000 + RENDER_RESAMPLE 3 0 1 + RENDER_ADDTOPROJ 0 + RENDER_STEMS 0 + RENDER_DITHER 0 + RENDER_TRIM 0.000001 0.000001 0 0 + TIMELOCKMODE 1 + TEMPOENVLOCKMODE 1 + ITEMMIX 1 + DEFPITCHMODE 589824 0 + TAKELANE 1 + SAMPLERATE 44100 0 0 + + LOCK 1 + + + GLOBAL_AUTO -1 + PLAYRATE 1 0 0.25 4 + SELECTION 0 0 + SELECTION2 0 0 + MASTERAUTOMODE 0 + MASTERTRACKHEIGHT 0 0 + MASTERPEAKCOL 16576 + MASTERMUTESOLO 0 + MASTERTRACKVIEW 0 0.6667 0.5 0.5 0 0 0 0 0 0 0 0 0 0 0 + MASTERHWOUT 0 0 1 0 0 0 0 -1 + MASTER_NCH 2 2 + MASTER_VOLUME 1 0 -1 -1 1 + MASTER_PANMODE 3 + MASTER_PANLAWFLAGS 3 + MASTER_FX 1 + MASTER_SEL 0 + + + + + RULERHEIGHT 86 86 + RULERLANE 1 4 "" 0 -1 + RULERLANE 2 8 "" 0 -1 + + TEMPO 95.0 4 4 0 + + + + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {0717373B-819A-468F-B2B7-A6B38B6B3872} + > + > + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + "" + ZFJ0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAzAEAAAEAAAAAAAAA + V0lER0VUID0gUmFkaWF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwMzgwMDEwMTAwMDE3NF1IRlE3Sz9X + PURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM9PUhaOUlHN2A6bjxJVk1oN09XNW5dZmxPVDVVZjZfVW5GZ2dBW0M6SzhSaGZeWFNoOGQw + XmRCUF5PT0JUT1pQM2k0WTVHYmFqWTlARVBeaVtVOjBvNTtVXlJIPDg2al89XEVASEZHWltVVz9YakVhVlNqOGVFNkhfU2tUUVhkaWNiMVQ1W2VVb10wOWpVYWpVM2Y9 + TDZoUlwxZGxjXjVeTVdaVEFHOzhNaVxlWGlIST84SUlRbGBZblU6Ym1RRUE+TllMTllAbVNHMVpKWDdqW1UwTEJnRzsyXkBYTzJkMVphN2IxNFcwW0JfYjZDRFY8QGFC + ODBFbGNNOjI3RWczQE0+akpmXj1GU2dBY0dfTWRGaGc+WDtoYEhaOTxGMDU1ZFNFbTBTVDhcTD9QSj8zWEU7T2VnMDAwMTMwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {561A1761-185B-4858-9A43-CE0BBA75891F} + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + > + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {0BA93AC5-4AFC-44DA-BBDD-19614774A2D5} + > + AUXRECV 7 0.050000 -1 -1 0 + AUXRECV 8 0.020000 -1 -1 0 + > + + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + "" + bUZMR+5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAGUAAAABAAAAAAAAAA== + dGZmcAAAAQBTVEdGMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0QAAAAAAAiNNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAEAAAA= + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {BA3E2976-6145-4DEC-A3B0-8E38AF53D7C4} + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + > + + "" + TVB0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAXQkAAAEAAAAAAAAA + V0lER0VUID0gUGhhc2VNaXN0cmVzczsNVkVSU0lPTiA9IDQ7DVNBVkVEX0JZX1ZFUlNJT04gPSA1LjAuMTsNUFJFU0VUID0wMTAxMDAwMDIzMTIwMTAxMDAwNmE0XUhG + UTdLP1c9RGc/WzxMRVQ4PVo6XUhWWFk+Uj5dSTRnbFZhNV0yWGA1W0gwSk1nZmNiYz9APGZCRWw4YDwxPkFLbl5nWz9PRURCV25vUzdfXWI6aVFaNkBCSFQ3T0tCZUBk + VkZdQDMxU2dCOUZDYWpcVF9tQFs4QThEXG9qXW1IWEw+PWRAPTJGZEBINWNqSEFRSEZgYGtDRDtQVExjUWdfazFIOmpuY1BGU1VRVGxRVVY3YE09O1BSW1Fhbz40N1ps + YVFZPFROSGY7NDRPYUFmRjo4TT07UFJbUWFvPjQ3WmxhUVk8VE5IaVMySGxbZ2VgTTpRZWE4UjxrSzE+NkFqb1lpMV8/OEpgMDViQmhiTDFaPGc0X0ZWWTNVaUdjY180 + Qj5nbVRlUDg/OVlpbFBmTl5kR2RXRkBaVmRPPFNNUWRUYF9iU0s2N0VcT0M7blRJR0E/T2lLUj5EXjFmVDlEOkNCVmVlRGpDblNFYWpCSjFlVk5ZTE5ZQG1TRzFRUE5Z + TFNsSDw2W2JBVTJkXmxfNGJjU1NPSEo+SlRcZ0tKbWk3S1pAVWgwS1NkSjZiR1VoRmBPNl1IMUloT0U4R0ZVODdlbT1OMD1cNlg7TGA6TzloO0BaOUFeWWNsbj85a1dP + bWBORVpeb1o7bTVSQGo6Z1hQUGBBUDI1TmRTWGwzOUNuW081b21pa15TNm9QYGtqSl9qQGpZT2ZGamk4VjpoSmM1MEhjZDA2VTVZMFFbNU43S0tWNlBpbV9pQmI0XFoz + XUpdYEdDajtANkhIQG1pNk9sVz9jZT4+NTRlXmtHbjg/OVk2SjdbZjBJRUNPXl9KRUNKPWxJQlU/MWJsSl9eb2RNYkQ5XGYxZTQ5OjJIMU81ZT41PlxRNj9TXmg2Z09b + PG5cSGQ0QThUNlg3RzpFU2VYZEU/VFM6NjVJQ2xpVWFCVz1KaTU7W1VhOF9IP1dRSkRWSElrPUNFS25kQ1Jfb1g0O0dUNVJgT2QzZlU+YGk9OVA8UG5LQmNgY0Y/Rm1G + NkJeOmRTW0NhP2RITjRaQ0ppVWZNYk1SM2poQmc6RD1cRFQ8Oz1uVzlrXVZPbEljQjZjRGVvME5YS1RMVkdJZzlmOD9bUTtMWUBmYUJAYFxnaENiTltEPEFUTEhTTVFk + VGBfYlNLNkw0PFZgSjVmN1hiUFNmVTBnP21cOmNBRG5CPFhIRVU/YE9kM2ZVPmBpPTlkTmhZWjA9XzFZP29vZGFtX0VCYU5bPmhUU2QwXVE5WkRGTFJWQGlFNjkzMDFr + PTpCZklRYkNdQUlPYkdQVGk9Plw2YUdQSkBFOERgSWw+T1xNTWZMTEJjMF5VTzJAaDdAWjlBXlljbG4/OWtXT21gTkVaXm9CXWk2ZmA8P0NhWEBWVDRsSTQwTTowOjFc + WTZqUTxhWmNOSVJKMzI6U0dqY0RlbzBOWEtUTFE9YU5UOm5QUWFVbDJKUmZXSlE9TTE+XDZhR1BKQEU4TURAVTlaUVtMMldQNWJQZk9GV2pUaWtCYVY2amVCM2VRSk0w + V25GN1tVS1JVP2hiTmo0UmpoW0NpYjhoYjc2Nls3WmBUMm9UM0RTOltXOmkyUU9DRThUQlRYTkpSM0xNbVQ/UThnMTpAMERNPVheYD1VRFxGZkdtQDU+X0lEUD03QUxa + VzlLRjFeMD4zbG1bbmZhNT4yaVZBTkQ0XWY5TWdZM2xlQVRYR0g7PkVaX2FsM1A+YEFVNlFfWGI8UmBCNDYzbUc0b0JPPmszPFpiY183UjFIb0lbNWk9RGpWQ0tdSG9C + NW9qM2tTSlheNGk7OVdhZmttYlU1XV04QzJNQU9UNFQ6VG9rVlswSVhmMV9dMklHSDhFQUtEOTBNT2k4bFg8U19iUjo/SWxfYWRIOF9mQUBdU29BXTdPVThcQDJnMGlt + WjVgWGdraGZAXE1aT2hpV2FGVmo+TEBNUTg+YTxjUm5nNkxVQU85bDNtU1VXXjE+ZWFWU0g2bmQ5VU1QUUU1XUBUMWVvVFNQRztIblk0REBbWzxoX11hVzlER2JJZDJb + RUY4U0lobEVVMjZtVUBlWlJKXWZebFtXbjpTZWZmNmM/MkZfRlk4SGZUbE9hXzpuUj5ASVZGQlVBQ2I8MzQ6TjhsQF1cSD9PYD5abGNhS0UybVZmZF88YUFkb2FUaGpX + WG9FXE5ZYDRWa0VCUFVAbDJdUmtMZUliR187YkM0Ok44bEBdXEg/TkxnY1xDY0RaT0ZQYEdPSEBeXUtOWUhePmpOMUA3RF88Oj9GRDtRTUNIVTZPM05MRkQ4QmFaQl1E + YUNlO1FUMk1eRENMTWA8P0k0MjJOWmo4YjBrQUFPM05Qam49ZEZFOj01QTdKYz5cWGxlV1A7MD80MWFcNUhHb2U4WWkzPTltZF5DTD5TW25kbTdpXkZkM1JWNTU7QUZG + bmJNZF5NR1BXMlJSP0FUPj9rW1Q5PEZMRl5YP11bNUxjWFhQR1pHMFpkaT9NYVQ9TDJtM1Y9ZkJGPE5SRzdnY1VMYVc6V2g7NjddMkwxZU9eM1hbWW1ZV04xNG5SNVpC + OEo2ZWVcRTJdUWRCXl80Sk1NOU9uMTNSNE84QWE1X1NiZ09SMUQ6X0BpU01UVVM3WFRoX2Y7PlJuNFVjZzEwUjE1MllbQkY1NjlROjw2SFphTk5GWVhHbDFLbjdoUEBj + Z2AxWj5jXjtLWVlqZWBLVDZKbTNWPWZCRjxOUkBHUU9fWT9DV2w4QkdvUEBoUTdiNExBS2hsXWdoUEUyXWFdXlpnVDFHSG1dREdBS2RZbUprZzRkbko1V0xGTjRRQVJI + QlMxVjpcR1VFNDtDbFlRbTRBbkVBXGxdV1tiNm1OMUdiNFBhVjpfa2ROZVAyM2lcMEdXZUVRUT1WbVZkWjtgOUptT2lWTjAxVExqZGNQaFRsTjBkbTBWNGREamFRT1Vl + Pj9sNlxsWmJKVWRMUVdRRltOTllMTllAbVNHMVZTX0lOYE5oUzxTUDNVNW5DXWRFRzZibWs7ZU9DNEBGSjFqZGtXTUljRVJuMVhsPlFEXW9HTDAwMEpkMDAwMDsN + AAAAAAAA + > + "" + QkV0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAA6wYAAAEAAAAAAAAA + V0lER0VUID0gRWNob0JveTsNVkVSU0lPTiA9IDQ7DVNBVkVEX0JZX1ZFUlNJT04gPSA1LjAuMTsNUFJFU0VUID0wMTAxMDAwMDE2OTIwMTAxMDAwNG00XUhGUTdLP1c9 + RGc/WzxMRVQ4PVo6XUhWWFk+Uj5dSTRnbFZhNV0yWGA1W0gwSk1nZmNiYz9FQGM5NGtWSUJqX2RON19iaWUxb2JYMTJNUT48X0xLRTpZUlJlOWdnPFdpMGpUP1FYY1Vl + R29oZWVbMlA5XTdJZ2hAPGFDWkQ3PWY7NlJCMjtsNkROQDlGYEBJXFRPOkZpP2Q0QlU/OkxJaENbPkxMZVVsTTlnVU1QVkI6QjJhOUdGbkRpP05CZDdbR244PzlZNko3 + W1JhOzg6PVJKTEg1R0ZuRGk/TkJkN1tHbjg/OVk2Sjdbb0o5OU5YZEFdZzYxOV9WWURSTmBHOl1sb2pKMlhhPkZCOm1QOz5VUURdT0w0bVxrUkJVRV04a141MlNDPEll + Qj5FN1EzYjhNQ0FDOGhAZzhiYzY5aExjUm5nNkxVQU85W1BGQWtbaV9Gb11Ja0lLUDBkQV1dYTljQWZoXG9jU0leZjk0QjxVQT1NQVU2UV9YYjxSYEI0NjNtRzRvQk8+ + a29BTzlRNDhQR09APV9PXVpiYUJcSWs+S0dVS0FeazZBWkNIMlM/MltLa1cxYWZgPFpXbVtbXWNNRkhLb2NQNURAaDtSOGhiQTBSVTZnOG9KRGJJU21dZ208XTU+UjFL + XGo6ak9KSWdQQT9ab0hTNDpYbGVTaD5OYjtiaW03SFhCV1pHN1pEP0hlYEhkYF1oTm9NaG1TUk9fUVZSWWQ8amNYU2I4b2RURF05WGBZRmo2U25AMWhdTE9bY25jPlkw + PV0+bks0Tl8yRVo3MGFBVGZAUW1sWjFfWT9KXzhjbjFBVVJoa1loNTBNQm5nMEJIWUNHYExqSFBWO0lQYU9ZSklDWWxKT09ZR1NFRENGQ1dvaDRgUkZqOT80XURqOlRH + azk1MkRsSTc8Tl8xQzNkPklrXDY5MTlqVWFqVTNmPUw2PF1SSW5naWVLVlptSWlIakNCV2BhWzBUUDlSSllUNEBgWW5VOmJtUUVBPlJQPzZWMmlkTzRdW29fXFw0Wm5W + W1ZsRmE+MDhgOl45SVU0blMySThPb1xiYFk1Qk5qU2JlNFZrRUJQVUBsMl9iV0U+MTRXZzdBTGNSbmc2TFVBTzloTmhlTlpJVlc0TFtoVFZrQlxDVkNmREo2blM4Yjsx + OEBIP2VMQ205bGtfbTVsVjRAUjFNbTBmbW5mWzs1OmFXXGldTkVdNmtcSTRgPD5mbFZKXW9FOjFqZGtXTUljRVI5XUBBOjFaOTpoYTpDZjZDTlc1N1ZmSj1QS2tAVkVm + Mms5YFAxMjRaa2EyQlY0UFRDWjloZUdWbDtqbGxFOk8xMjNLWVVONllXUWJPSkleVFtsYUU2ajFqZGtXTUljRVI5XUBBOjFaOTpoY2lJX0tkZEpVajdBSklJZWxJRTJf + al1jV15CSDhNU0ZqX1dePTBdYVs+Sz9SN2xfUzVCaG5tS2xCQFRpTU9nUVc3M0JqNjROMWRLNk5aQTVMXFFnVmM6R0BuQU1YY2ZSR29YbVw4TDdUaltbZVdVU1k9Ok8z + NkZuW1U6VTNZbkNASDJjOU9ZWkVORzRvMElvQjtAZ2RANl9LQGdDXTlCN0JqNV9gTGdWS0xQRGRlR11aSD0wXDVqMUNBN1w4OWJhbW8waltjPzVdRDtmS0tCbGM1N0Nv + NkRARGZYMVJRVzBQbmtlblxDYmFkZ2hqa19BTDVubVYzWDdbQ15NZVc9RjtIMVVFPW5qbVlFODo8PEY3ZDtMTWg+ZFJMXD1DOjdZNDtYSWpqXVVval01PE5FakNta0Rr + WWNQXERdTz1ANVNDb05uTz9UQz1RQ0NGZFNjQU9WOVJUW109a0c/Yzc4QzVhOkdaRkFuRElcRjMyaVEwUG9BUWRvREFfR2VOWGZsUmdDaTJXM21OVzZkbFZCWWBKU19X + Qk4ySFA3QjA6OkFKOEo/W1tPPFgzSFFgWVRWQUNNX0NpSlpsbj1TUjQzUk9IUW83QjdOQG5EZ1xKNU5IY15kQ00+OWRgMWdjT1JuV0U6REEyR2s/OkJNYENmY145OkVG + ZFMyYT8wOlVFPkRtVlNINm5kOVVNUF05azFCNUo2Sl5IRlwxZT5RYjNEXGlsalxeb2NjOUxJWjFqZGtXTUljRVJlYV5iPUU1ZUg7Z1lLYEJaWERsR2xDY1NiQDNAZkgx + Z1U7QjJlRDAyZj9hZFNFbTBTVDhcTD9QSj8zWEU7T2VnMDAwNGkwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {5036A77F-65E2-4AA4-B524-43233FBE8F89} + > + AUXRECV 7 0.150000 -1 -1 0 + AUXRECV 8 0.080000 -1 -1 0 + + > + + > + + > + + > + + > + + > + + > + > + + "" + bVR0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAwgMAAAEAAAAAAAAA + V0lER0VUID0gVHJlbW9sYXRvcjsNVkVSU0lPTiA9IDQ7DVNBVkVEX0JZX1ZFUlNJT04gPSA1LjAuMTsNUFJFU0VUID0wMTAxMDAwMDA4ODAwMTAxMDAwMlU0XUhGUTdL + P1c9RGc/WzxMRVQ4PVo6XUhWWFk+Uj5dSTRnbFZhNV0yWGA1W0gwSk1nZmNiYzwyRTppQEBsQz89ZUJDb0hUNz5kMUUxaGs+bT1CTVVAZjJFTkZnU0tTX2s4OE05U101 + X1NrT1xhP0g2UlptMVVSX1U6ZzNJNWZeSFwxTGVeNWNMYUM1ZkZYX19nODJEbGQ2RlYwUzs6Vj8zWTVFakg0QURNOG5rQFReZD84ZzJiNjNlTzZRUk5QUD5GTlpBNUxc + UWdWYzlTPTVAbENBTWQ+M2VPNlFSTlBQPkZOWkE1TFxRZ1ZjUWxQUVpqaD5NZkk5T24xM1I0TzhBZzZfYT07OkVjUENeaU9oPko9NjRGTkBCWD5cZTNLQFoyPE5tbWY2 + P0JRP0psRmoyYz5AMUI7UF5HOWhYPDFjXzMxR2JSX0lHVmhmS1NkSjZiR1VoRmI1T0NSUGNFWlI4Wm09bjczamxbZGo8TzNIY1taaGdkakBaYUkxWFtjUjxCSURVSWtU + akE0PVIyMFc/V2dIM2BiUTw+XWBqZD40M29lbDxnXW1EOmU5YkFsVjlYMFYwPFw8Xm5EbjxHZVVKPl1nRV9KT15lZERQMU5kRltIO2BLVkc5R0tFNGhDajc5QkNHYT5N + UUdYazs2N10yTDFlT14zWFtZbVlXTjE0bllramhUZ24zN2RiSztoWzBpSWZlZ2o3ZTxmUU1bMVxUXmA5W2lHamRINTFNYkA8bUBSOWBaM1BBamg7QjM1WzlnPlpTWjRc + VjxcVmkyUUZLPWRdWmI3VTtIMDs9ZV45NGVfWmlCWUBqT1RkOF9ha2RhNDhXNjBFQUtEOTBNT2k4aDViZj9aQTU0OmpjPjtrTEliRTVsVl4xSTdeX1ZtS25lV11VXjAz + QTZmZzRXPTdLUmNvPj1VbUNtT2BDYVxhRkZESjZuUzhiOzE4QEg/ZUxDbTlsa1xCUUA/YjdIMDNrbGNSbmc2TFVBTzlbUEZBa1tpX0ZvXFFabkZQRFc/UGJKYWZMUV89 + T0U+QGdYQWxnVVlhMUkzYDU4YWxdSEFsVEZJPFFXSGw2XDBuYkFMXUBVZG05PEhQQzBHTUtjZGg3T1xcaT1YS0hWTlZjSjI8Zm5na2JTSkhPbmVEaj8zPUNAMDAwWEww + Ow0= + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {0CDC1C27-A028-4AAE-AC98-10626198FF77} + > + AUXRECV 7 0.100000 -1 -1 0 + AUXRECV 8 0.050000 -1 -1 0 + + > + + > + + > + > + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {A4571C4B-6F28-4400-B4B8-E0B843F880C3} + > + AUXRECV 7 0.050000 -1 -1 0 + AUXRECV 8 0.020000 -1 -1 0 + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + > + + "" + owDRY+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAVQQAAAEAAAD//wAA + RQQAAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAABC1GQkNoAAAAAmRMYXkAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {8AF4C7EC-87EB-4099-927D-041CED5CE0FC} + > + AUXRECV 7 0.250000 -1 -1 0 + AUXRECV 8 0.150000 -1 -1 0 + + > + + > + + > + + > + + > + > + "" + xz/rIu5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAASwMAAAEAAAAAAAAA + OAIAAAEAAABGRkJTAQAAAIgAAAAAAAA/AAAAAAAAAD8AAAA/AAAAAJqZmT4AAAAAMzMzPwAAAADIAbRBAAAAAAAAAAAAAHpDAAAAAAAAAAAAAAAAJCeEPQAAAAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {C288C420-20A8-49F2-8C2A-4387DF9B6CF6} + > + > + "" + owDRY+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAVQQAAAEAAAD//wAA + RQQAAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAABC1GQkNoAAAAAmRMYXkAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {84731E3F-3910-4605-968D-3A96380112B5} + > + > +> diff --git a/output/test_song_check.rpp b/output/test_song_check.rpp new file mode 100644 index 0000000..07f377b --- /dev/null +++ b/output/test_song_check.rpp @@ -0,0 +1,2488 @@ + + + RIPPLE 0 0 + GROUPOVERRIDE 0 0 0 0 + AUTOXFADE 129 + ENVATTACH 3 + POOLEDENVATTACH 0 + TCPUIFLAGS 0 + MIXERUIFLAGS 11 48 + ENVFADESZ10 40 + PEAKGAIN 1 + FEEDBACK 0 + PANLAW 1 + PROJOFFS 0 0 0 + MAXPROJLEN 0 0 + GRID 3199 8 1 8 1 0 0 0 + TIMEMODE 1 5 -1 30 0 0 -1 0 + VIDEO_CONFIG 0 0 65792 + PANMODE 3 + PANLAWFLAGS 3 + CURSOR 0 + ZOOM 100 0 0 + VZOOMEX 6 0 + USE_REC_CFG 0 + RECMODE 1 + SMPTESYNC 0 30 100 40 1000 300 0 0 1 0 0 + LOOP 0 + LOOPGRAN 0 4 + RECORD_PATH Media "" + + + + + RENDER_FILE "" + RENDER_PATTERN "" + RENDER_FMT 0 2 0 + RENDER_1X 0 + RENDER_RANGE 1 0 0 0 1000 + RENDER_RESAMPLE 3 0 1 + RENDER_ADDTOPROJ 0 + RENDER_STEMS 0 + RENDER_DITHER 0 + RENDER_TRIM 0.000001 0.000001 0 0 + TIMELOCKMODE 1 + TEMPOENVLOCKMODE 1 + ITEMMIX 1 + DEFPITCHMODE 589824 0 + TAKELANE 1 + SAMPLERATE 44100 0 0 + + LOCK 1 + + + GLOBAL_AUTO -1 + PLAYRATE 1 0 0.25 4 + SELECTION 0 0 + SELECTION2 0 0 + MASTERAUTOMODE 0 + MASTERTRACKHEIGHT 0 0 + MASTERPEAKCOL 16576 + MASTERMUTESOLO 0 + MASTERTRACKVIEW 0 0.6667 0.5 0.5 0 0 0 0 0 0 0 0 0 0 0 + MASTERHWOUT 0 0 1 0 0 0 0 -1 + MASTER_NCH 2 2 + MASTER_VOLUME 1 0 -1 -1 1 + MASTER_PANMODE 3 + MASTER_PANLAWFLAGS 3 + MASTER_FX 1 + MASTER_SEL 0 + + + + + RULERHEIGHT 86 86 + RULERLANE 1 4 "" 0 -1 + RULERLANE 2 8 "" 0 -1 + + TEMPO 95.0 4 4 0 + + + + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {0717373B-819A-468F-B2B7-A6B38B6B3872} + > + > + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + "" + ZFJ0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAzAEAAAEAAAAAAAAA + V0lER0VUID0gUmFkaWF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwMzgwMDEwMTAwMDE3NF1IRlE3Sz9X + PURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM9PUhaOUlHN2A6bjxJVk1oN09XNW5dZmxPVDVVZjZfVW5GZ2dBW0M6SzhSaGZeWFNoOGQw + XmRCUF5PT0JUT1pQM2k0WTVHYmFqWTlARVBeaVtVOjBvNTtVXlJIPDg2al89XEVASEZHWltVVz9YakVhVlNqOGVFNkhfU2tUUVhkaWNiMVQ1W2VVb10wOWpVYWpVM2Y9 + TDZoUlwxZGxjXjVeTVdaVEFHOzhNaVxlWGlIST84SUlRbGBZblU6Ym1RRUE+TllMTllAbVNHMVpKWDdqW1UwTEJnRzsyXkBYTzJkMVphN2IxNFcwW0JfYjZDRFY8QGFC + ODBFbGNNOjI3RWczQE0+akpmXj1GU2dBY0dfTWRGaGc+WDtoYEhaOTxGMDU1ZFNFbTBTVDhcTD9QSj8zWEU7T2VnMDAwMTMwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {561A1761-185B-4858-9A43-CE0BBA75891F} + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + > + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {0BA93AC5-4AFC-44DA-BBDD-19614774A2D5} + > + AUXRECV 7 0.050000 -1 -1 0 + AUXRECV 8 0.020000 -1 -1 0 + > + + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + "" + bUZMR+5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAGUAAAABAAAAAAAAAA== + dGZmcAAAAQBTVEdGMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0QAAAAAAAiNNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAEAAAA= + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {BA3E2976-6145-4DEC-A3B0-8E38AF53D7C4} + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + > + + "" + TVB0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAXQkAAAEAAAAAAAAA + V0lER0VUID0gUGhhc2VNaXN0cmVzczsNVkVSU0lPTiA9IDQ7DVNBVkVEX0JZX1ZFUlNJT04gPSA1LjAuMTsNUFJFU0VUID0wMTAxMDAwMDIzMTIwMTAxMDAwNmE0XUhG + UTdLP1c9RGc/WzxMRVQ4PVo6XUhWWFk+Uj5dSTRnbFZhNV0yWGA1W0gwSk1nZmNiYz9APGZCRWw4YDwxPkFLbl5nWz9PRURCV25vUzdfXWI6aVFaNkBCSFQ3T0tCZUBk + VkZdQDMxU2dCOUZDYWpcVF9tQFs4QThEXG9qXW1IWEw+PWRAPTJGZEBINWNqSEFRSEZgYGtDRDtQVExjUWdfazFIOmpuY1BGU1VRVGxRVVY3YE09O1BSW1Fhbz40N1ps + YVFZPFROSGY7NDRPYUFmRjo4TT07UFJbUWFvPjQ3WmxhUVk8VE5IaVMySGxbZ2VgTTpRZWE4UjxrSzE+NkFqb1lpMV8/OEpgMDViQmhiTDFaPGc0X0ZWWTNVaUdjY180 + Qj5nbVRlUDg/OVlpbFBmTl5kR2RXRkBaVmRPPFNNUWRUYF9iU0s2N0VcT0M7blRJR0E/T2lLUj5EXjFmVDlEOkNCVmVlRGpDblNFYWpCSjFlVk5ZTE5ZQG1TRzFRUE5Z + TFNsSDw2W2JBVTJkXmxfNGJjU1NPSEo+SlRcZ0tKbWk3S1pAVWgwS1NkSjZiR1VoRmBPNl1IMUloT0U4R0ZVODdlbT1OMD1cNlg7TGA6TzloO0BaOUFeWWNsbj85a1dP + bWBORVpeb1o7bTVSQGo6Z1hQUGBBUDI1TmRTWGwzOUNuW081b21pa15TNm9QYGtqSl9qQGpZT2ZGamk4VjpoSmM1MEhjZDA2VTVZMFFbNU43S0tWNlBpbV9pQmI0XFoz + XUpdYEdDajtANkhIQG1pNk9sVz9jZT4+NTRlXmtHbjg/OVk2SjdbZjBJRUNPXl9KRUNKPWxJQlU/MWJsSl9eb2RNYkQ5XGYxZTQ5OjJIMU81ZT41PlxRNj9TXmg2Z09b + PG5cSGQ0QThUNlg3RzpFU2VYZEU/VFM6NjVJQ2xpVWFCVz1KaTU7W1VhOF9IP1dRSkRWSElrPUNFS25kQ1Jfb1g0O0dUNVJgT2QzZlU+YGk9OVA8UG5LQmNgY0Y/Rm1G + NkJeOmRTW0NhP2RITjRaQ0ppVWZNYk1SM2poQmc6RD1cRFQ8Oz1uVzlrXVZPbEljQjZjRGVvME5YS1RMVkdJZzlmOD9bUTtMWUBmYUJAYFxnaENiTltEPEFUTEhTTVFk + VGBfYlNLNkw0PFZgSjVmN1hiUFNmVTBnP21cOmNBRG5CPFhIRVU/YE9kM2ZVPmBpPTlkTmhZWjA9XzFZP29vZGFtX0VCYU5bPmhUU2QwXVE5WkRGTFJWQGlFNjkzMDFr + PTpCZklRYkNdQUlPYkdQVGk9Plw2YUdQSkBFOERgSWw+T1xNTWZMTEJjMF5VTzJAaDdAWjlBXlljbG4/OWtXT21gTkVaXm9CXWk2ZmA8P0NhWEBWVDRsSTQwTTowOjFc + WTZqUTxhWmNOSVJKMzI6U0dqY0RlbzBOWEtUTFE9YU5UOm5QUWFVbDJKUmZXSlE9TTE+XDZhR1BKQEU4TURAVTlaUVtMMldQNWJQZk9GV2pUaWtCYVY2amVCM2VRSk0w + V25GN1tVS1JVP2hiTmo0UmpoW0NpYjhoYjc2Nls3WmBUMm9UM0RTOltXOmkyUU9DRThUQlRYTkpSM0xNbVQ/UThnMTpAMERNPVheYD1VRFxGZkdtQDU+X0lEUD03QUxa + VzlLRjFeMD4zbG1bbmZhNT4yaVZBTkQ0XWY5TWdZM2xlQVRYR0g7PkVaX2FsM1A+YEFVNlFfWGI8UmBCNDYzbUc0b0JPPmszPFpiY183UjFIb0lbNWk9RGpWQ0tdSG9C + NW9qM2tTSlheNGk7OVdhZmttYlU1XV04QzJNQU9UNFQ6VG9rVlswSVhmMV9dMklHSDhFQUtEOTBNT2k4bFg8U19iUjo/SWxfYWRIOF9mQUBdU29BXTdPVThcQDJnMGlt + WjVgWGdraGZAXE1aT2hpV2FGVmo+TEBNUTg+YTxjUm5nNkxVQU85bDNtU1VXXjE+ZWFWU0g2bmQ5VU1QUUU1XUBUMWVvVFNQRztIblk0REBbWzxoX11hVzlER2JJZDJb + RUY4U0lobEVVMjZtVUBlWlJKXWZebFtXbjpTZWZmNmM/MkZfRlk4SGZUbE9hXzpuUj5ASVZGQlVBQ2I8MzQ6TjhsQF1cSD9PYD5abGNhS0UybVZmZF88YUFkb2FUaGpX + WG9FXE5ZYDRWa0VCUFVAbDJdUmtMZUliR187YkM0Ok44bEBdXEg/TkxnY1xDY0RaT0ZQYEdPSEBeXUtOWUhePmpOMUA3RF88Oj9GRDtRTUNIVTZPM05MRkQ4QmFaQl1E + YUNlO1FUMk1eRENMTWA8P0k0MjJOWmo4YjBrQUFPM05Qam49ZEZFOj01QTdKYz5cWGxlV1A7MD80MWFcNUhHb2U4WWkzPTltZF5DTD5TW25kbTdpXkZkM1JWNTU7QUZG + bmJNZF5NR1BXMlJSP0FUPj9rW1Q5PEZMRl5YP11bNUxjWFhQR1pHMFpkaT9NYVQ9TDJtM1Y9ZkJGPE5SRzdnY1VMYVc6V2g7NjddMkwxZU9eM1hbWW1ZV04xNG5SNVpC + OEo2ZWVcRTJdUWRCXl80Sk1NOU9uMTNSNE84QWE1X1NiZ09SMUQ6X0BpU01UVVM3WFRoX2Y7PlJuNFVjZzEwUjE1MllbQkY1NjlROjw2SFphTk5GWVhHbDFLbjdoUEBj + Z2AxWj5jXjtLWVlqZWBLVDZKbTNWPWZCRjxOUkBHUU9fWT9DV2w4QkdvUEBoUTdiNExBS2hsXWdoUEUyXWFdXlpnVDFHSG1dREdBS2RZbUprZzRkbko1V0xGTjRRQVJI + QlMxVjpcR1VFNDtDbFlRbTRBbkVBXGxdV1tiNm1OMUdiNFBhVjpfa2ROZVAyM2lcMEdXZUVRUT1WbVZkWjtgOUptT2lWTjAxVExqZGNQaFRsTjBkbTBWNGREamFRT1Vl + Pj9sNlxsWmJKVWRMUVdRRltOTllMTllAbVNHMVZTX0lOYE5oUzxTUDNVNW5DXWRFRzZibWs7ZU9DNEBGSjFqZGtXTUljRVJuMVhsPlFEXW9HTDAwMEpkMDAwMDsN + AAAAAAAA + > + "" + QkV0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAA6wYAAAEAAAAAAAAA + V0lER0VUID0gRWNob0JveTsNVkVSU0lPTiA9IDQ7DVNBVkVEX0JZX1ZFUlNJT04gPSA1LjAuMTsNUFJFU0VUID0wMTAxMDAwMDE2OTIwMTAxMDAwNG00XUhGUTdLP1c9 + RGc/WzxMRVQ4PVo6XUhWWFk+Uj5dSTRnbFZhNV0yWGA1W0gwSk1nZmNiYz9FQGM5NGtWSUJqX2RON19iaWUxb2JYMTJNUT48X0xLRTpZUlJlOWdnPFdpMGpUP1FYY1Vl + R29oZWVbMlA5XTdJZ2hAPGFDWkQ3PWY7NlJCMjtsNkROQDlGYEBJXFRPOkZpP2Q0QlU/OkxJaENbPkxMZVVsTTlnVU1QVkI6QjJhOUdGbkRpP05CZDdbR244PzlZNko3 + W1JhOzg6PVJKTEg1R0ZuRGk/TkJkN1tHbjg/OVk2Sjdbb0o5OU5YZEFdZzYxOV9WWURSTmBHOl1sb2pKMlhhPkZCOm1QOz5VUURdT0w0bVxrUkJVRV04a141MlNDPEll + Qj5FN1EzYjhNQ0FDOGhAZzhiYzY5aExjUm5nNkxVQU85W1BGQWtbaV9Gb11Ja0lLUDBkQV1dYTljQWZoXG9jU0leZjk0QjxVQT1NQVU2UV9YYjxSYEI0NjNtRzRvQk8+ + a29BTzlRNDhQR09APV9PXVpiYUJcSWs+S0dVS0FeazZBWkNIMlM/MltLa1cxYWZgPFpXbVtbXWNNRkhLb2NQNURAaDtSOGhiQTBSVTZnOG9KRGJJU21dZ208XTU+UjFL + XGo6ak9KSWdQQT9ab0hTNDpYbGVTaD5OYjtiaW03SFhCV1pHN1pEP0hlYEhkYF1oTm9NaG1TUk9fUVZSWWQ8amNYU2I4b2RURF05WGBZRmo2U25AMWhdTE9bY25jPlkw + PV0+bks0Tl8yRVo3MGFBVGZAUW1sWjFfWT9KXzhjbjFBVVJoa1loNTBNQm5nMEJIWUNHYExqSFBWO0lQYU9ZSklDWWxKT09ZR1NFRENGQ1dvaDRgUkZqOT80XURqOlRH + azk1MkRsSTc8Tl8xQzNkPklrXDY5MTlqVWFqVTNmPUw2PF1SSW5naWVLVlptSWlIakNCV2BhWzBUUDlSSllUNEBgWW5VOmJtUUVBPlJQPzZWMmlkTzRdW29fXFw0Wm5W + W1ZsRmE+MDhgOl45SVU0blMySThPb1xiYFk1Qk5qU2JlNFZrRUJQVUBsMl9iV0U+MTRXZzdBTGNSbmc2TFVBTzloTmhlTlpJVlc0TFtoVFZrQlxDVkNmREo2blM4Yjsx + OEBIP2VMQ205bGtfbTVsVjRAUjFNbTBmbW5mWzs1OmFXXGldTkVdNmtcSTRgPD5mbFZKXW9FOjFqZGtXTUljRVI5XUBBOjFaOTpoYTpDZjZDTlc1N1ZmSj1QS2tAVkVm + Mms5YFAxMjRaa2EyQlY0UFRDWjloZUdWbDtqbGxFOk8xMjNLWVVONllXUWJPSkleVFtsYUU2ajFqZGtXTUljRVI5XUBBOjFaOTpoY2lJX0tkZEpVajdBSklJZWxJRTJf + al1jV15CSDhNU0ZqX1dePTBdYVs+Sz9SN2xfUzVCaG5tS2xCQFRpTU9nUVc3M0JqNjROMWRLNk5aQTVMXFFnVmM6R0BuQU1YY2ZSR29YbVw4TDdUaltbZVdVU1k9Ok8z + NkZuW1U6VTNZbkNASDJjOU9ZWkVORzRvMElvQjtAZ2RANl9LQGdDXTlCN0JqNV9gTGdWS0xQRGRlR11aSD0wXDVqMUNBN1w4OWJhbW8waltjPzVdRDtmS0tCbGM1N0Nv + NkRARGZYMVJRVzBQbmtlblxDYmFkZ2hqa19BTDVubVYzWDdbQ15NZVc9RjtIMVVFPW5qbVlFODo8PEY3ZDtMTWg+ZFJMXD1DOjdZNDtYSWpqXVVval01PE5FakNta0Rr + WWNQXERdTz1ANVNDb05uTz9UQz1RQ0NGZFNjQU9WOVJUW109a0c/Yzc4QzVhOkdaRkFuRElcRjMyaVEwUG9BUWRvREFfR2VOWGZsUmdDaTJXM21OVzZkbFZCWWBKU19X + Qk4ySFA3QjA6OkFKOEo/W1tPPFgzSFFgWVRWQUNNX0NpSlpsbj1TUjQzUk9IUW83QjdOQG5EZ1xKNU5IY15kQ00+OWRgMWdjT1JuV0U6REEyR2s/OkJNYENmY145OkVG + ZFMyYT8wOlVFPkRtVlNINm5kOVVNUF05azFCNUo2Sl5IRlwxZT5RYjNEXGlsalxeb2NjOUxJWjFqZGtXTUljRVJlYV5iPUU1ZUg7Z1lLYEJaWERsR2xDY1NiQDNAZkgx + Z1U7QjJlRDAyZj9hZFNFbTBTVDhcTD9QSj8zWEU7T2VnMDAwNGkwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {5036A77F-65E2-4AA4-B524-43233FBE8F89} + > + AUXRECV 7 0.150000 -1 -1 0 + AUXRECV 8 0.080000 -1 -1 0 + + > + + > + + > + + > + + > + + > + + > + > + + "" + bVR0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAwgMAAAEAAAAAAAAA + V0lER0VUID0gVHJlbW9sYXRvcjsNVkVSU0lPTiA9IDQ7DVNBVkVEX0JZX1ZFUlNJT04gPSA1LjAuMTsNUFJFU0VUID0wMTAxMDAwMDA4ODAwMTAxMDAwMlU0XUhGUTdL + P1c9RGc/WzxMRVQ4PVo6XUhWWFk+Uj5dSTRnbFZhNV0yWGA1W0gwSk1nZmNiYzwyRTppQEBsQz89ZUJDb0hUNz5kMUUxaGs+bT1CTVVAZjJFTkZnU0tTX2s4OE05U101 + X1NrT1xhP0g2UlptMVVSX1U6ZzNJNWZeSFwxTGVeNWNMYUM1ZkZYX19nODJEbGQ2RlYwUzs6Vj8zWTVFakg0QURNOG5rQFReZD84ZzJiNjNlTzZRUk5QUD5GTlpBNUxc + UWdWYzlTPTVAbENBTWQ+M2VPNlFSTlBQPkZOWkE1TFxRZ1ZjUWxQUVpqaD5NZkk5T24xM1I0TzhBZzZfYT07OkVjUENeaU9oPko9NjRGTkBCWD5cZTNLQFoyPE5tbWY2 + P0JRP0psRmoyYz5AMUI7UF5HOWhYPDFjXzMxR2JSX0lHVmhmS1NkSjZiR1VoRmI1T0NSUGNFWlI4Wm09bjczamxbZGo8TzNIY1taaGdkakBaYUkxWFtjUjxCSURVSWtU + akE0PVIyMFc/V2dIM2BiUTw+XWBqZD40M29lbDxnXW1EOmU5YkFsVjlYMFYwPFw8Xm5EbjxHZVVKPl1nRV9KT15lZERQMU5kRltIO2BLVkc5R0tFNGhDajc5QkNHYT5N + UUdYazs2N10yTDFlT14zWFtZbVlXTjE0bllramhUZ24zN2RiSztoWzBpSWZlZ2o3ZTxmUU1bMVxUXmA5W2lHamRINTFNYkA8bUBSOWBaM1BBamg7QjM1WzlnPlpTWjRc + VjxcVmkyUUZLPWRdWmI3VTtIMDs9ZV45NGVfWmlCWUBqT1RkOF9ha2RhNDhXNjBFQUtEOTBNT2k4aDViZj9aQTU0OmpjPjtrTEliRTVsVl4xSTdeX1ZtS25lV11VXjAz + QTZmZzRXPTdLUmNvPj1VbUNtT2BDYVxhRkZESjZuUzhiOzE4QEg/ZUxDbTlsa1xCUUA/YjdIMDNrbGNSbmc2TFVBTzlbUEZBa1tpX0ZvXFFabkZQRFc/UGJKYWZMUV89 + T0U+QGdYQWxnVVlhMUkzYDU4YWxdSEFsVEZJPFFXSGw2XDBuYkFMXUBVZG05PEhQQzBHTUtjZGg3T1xcaT1YS0hWTlZjSjI8Zm5na2JTSkhPbmVEaj8zPUNAMDAwWEww + Ow0= + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {0CDC1C27-A028-4AAE-AC98-10626198FF77} + > + AUXRECV 7 0.100000 -1 -1 0 + AUXRECV 8 0.050000 -1 -1 0 + + > + + > + + > + > + "" + Y0R0U+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAOwIAAAEAAAAAAAAA + V0lER0VUID0gRGVjYXBpdGF0b3I7DVZFUlNJT04gPSA0Ow1TQVZFRF9CWV9WRVJTSU9OID0gNS4wLjE7DVBSRVNFVCA9MDEwMTAwMDAwNDg4MDEwMTAwMDFLNF1IRlE3 + Sz9XPURnP1s8TEVUOD1aOl1IVlhZPlI+XUk0Z2xWYTVdMlhgNVtIMEpNZ2ZjYmM+RGw5WzRDS2tpW1ZnM25nP0pdXVxKTUZjNGU1XV5vZmM+QWw0TjJqWjlkOmxsOVI7 + Wjs2SUpCTVY4N1U4XTlbPWtMbEdbaVo5RlJaXVVpMk1NQEFsbVViRWZlQT40blFjbUI7ODxSbFE+bG08WjA2MGs5QT9uaFI3MVxQWm5BYjo3TGw8ZTRSUl0/RmozRmdt + RDhvMGxKPzFHYF9tZFpBaWUzNmtqVjZvXWxtMzE4bkxtUDhfNlNlajRFVj1jMltlRDtFaDljVWlNajlQVEs3VkhfZEtoYzRpP11jUj1EZF5TXltiSjdDNEExMTlfVk5h + bTxWX2VrSlJlNzRmSmNIQExlMUVMWWBISEA9YjFZWEA2NjZYZ1xGWmE3NlFYQjYxVV9eaTpRXVFBPmNkazAxPDlqMDQxa1tAaU1MMG1HZzpjXlM4OGhVS1VSMVZZbmc/ + TTJYYUdQTT9Ca10wOWI4ZW8wakZMWGteTk83U2I+ND5hUTdUVG1gM0VDVUc6UFRbbE5bTkZTUFFiYllCb21obDY/OjM4RjpvNVdiS2BNbGAwMDA1UDAwMDA7DQ== + AAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {A4571C4B-6F28-4400-B4B8-E0B843F880C3} + > + AUXRECV 7 0.050000 -1 -1 0 + AUXRECV 8 0.020000 -1 -1 0 + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + + > + > + + "" + owDRY+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAVQQAAAEAAAD//wAA + RQQAAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAABC1GQkNoAAAAAmRMYXkAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {8AF4C7EC-87EB-4099-927D-041CED5CE0FC} + > + AUXRECV 7 0.250000 -1 -1 0 + AUXRECV 8 0.150000 -1 -1 0 + + > + + > + + > + + > + + > + > + "" + xz/rIu5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAASwMAAAEAAAAAAAAA + OAIAAAEAAABGRkJTAQAAAIgAAAAAAAA/AAAAAAAAAD8AAAA/AAAAAJqZmT4AAAAAMzMzPwAAAADIAbRBAAAAAAAAAAAAAHpDAAAAAAAAAAAAAAAAJCeEPQAAAAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {C288C420-20A8-49F2-8C2A-4387DF9B6CF6} + > + > + "" + owDRY+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAVQQAAAEAAAD//wAA + RQQAAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAABC1GQkNoAAAAAmRMYXkAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + > + PRESETNAME "Program 1" + FLOATPOS 0 0 0 0 + FXID {84731E3F-3910-4605-968D-3A96380112B5} + > + > +> diff --git a/scripts/_match_samples.py b/scripts/_match_samples.py new file mode 100644 index 0000000..bbb4d65 --- /dev/null +++ b/scripts/_match_samples.py @@ -0,0 +1,49 @@ +import json, hashlib, os + +# Load our sample index +idx = json.load(open('data/sample_index.json')) +samples = idx['samples'] + +# Build MD5 → sample map +md5_map = {} +for s in samples: + h = s.get('file_hash', '') + if h: + md5_map[h] = s + +# Ableton drumloop paths +ableton_dir = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\libreria\reggaeton\drumloops" +ableton_samples = [ + "100bpm filtrado drumloop.wav", + "90bpm reggaeton antiguo drumloop.wav", + "94bpm reggaeton antiguo 2 drumloop.wav", + "100bpm_gata-only_drumloop.wav", + "98bpm yera drumloop.wav", + "98bpm nachogflow drumloop.wav", + "90bpm reggaeton antiguo 3 drumloop.wav", +] + +print("Matching Ableton samples to our library:\n") +for name in ableton_samples: + path = os.path.join(ableton_dir, name) + if not os.path.exists(path): + print(f" NOT FOUND: {name}") + continue + + # Compute MD5 + h = hashlib.md5() + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b''): + h.update(chunk) + md5 = h.hexdigest() + + # Lookup in index + match = md5_map.get(md5) + if match: + print(f" {name}") + print(f" -> {match['original_name']}") + print(f" MD5: {md5}") + print(f" Path: {match['original_path']}") + else: + print(f" {name} -> NO MATCH (md5={md5})") + print() diff --git a/scripts/compose.py b/scripts/compose.py index 876a51f..ad34b5b 100644 --- a/scripts/compose.py +++ b/scripts/compose.py @@ -23,11 +23,14 @@ _ROOT = Path(__file__).parent.parent sys.path.insert(0, str(_ROOT)) from src.core.schema import ( - SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, + SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent, PluginDef, SectionDef, ) from src.selector import SampleSelector from src.reaper_builder import RPPBuilder, PLUGIN_REGISTRY, PLUGIN_PRESETS +from src.composer.chords import ChordEngine +from src.composer.melody_engine import build_motif, build_call_response +from src.calibrator import Calibrator # --------------------------------------------------------------------------- # Constants @@ -46,15 +49,15 @@ ABLETON_DRUMLOOP_DIR = Path( # Each section gets a drumloop variant: "seco", "filtrado", "empty", "seco" # This cycles through the sections DRUMLOOP_ASSIGNMENTS = { - "intro": "filtrado", # filtered intro - "verse": "seco", # dry verse - "build": "filtrado", # building with filter - "chorus": "seco", # full energy dry - "break": "empty", # breakdown — no drumloop - "chorus2": "seco", # full energy dry - "bridge": "filtrado", # filtered bridge - "final": "seco", # full energy - "outro": "filtrado", # filtered outro + "intro": "filtrado", # filtered intro + "verse": "seco", # dry verse + "pre-chorus": "filtrado", # building with filter + "chorus": "seco", # full energy dry + "verse2": "seco", # dry verse 2 + "chorus2": "seco", # full energy dry + "bridge": "filtrado", # filtered bridge + "final": "seco", # full energy + "outro": "filtrado", # filtered outro } # Drumloop files for each variant @@ -100,7 +103,7 @@ BASS_PATTERN_8BARS = [ SECTIONS = [ ("intro", 4, 0.3, False), ("verse", 8, 0.5, True), - ("build", 4, 0.7, False), + ("pre-chorus", 4, 0.7, False), ("chorus", 8, 1.0, True), ("verse2", 8, 0.5, True), ("chorus2", 8, 1.0, True), @@ -132,12 +135,13 @@ FX_CHAINS = { } SEND_LEVELS = { - "bass": (0.05, 0.02), - "chords": (0.15, 0.08), - "lead": (0.10, 0.05), - "clap": (0.05, 0.02), - "pad": (0.25, 0.15), - "perc": (0.05, 0.02), + "bass": (0.05, 0.02), + "chords": (0.15, 0.08), + "lead": (0.10, 0.05), + "clap": (0.05, 0.02), + "pad": (0.25, 0.15), + "perc": (0.05, 0.02), + "transition_fx": (0.08, 0.05), } VOLUME_LEVELS = { @@ -152,6 +156,56 @@ VOLUME_LEVELS = { MASTER_VOLUME = 0.85 +# --------------------------------------------------------------------------- +# FX Transitions — glue sections with risers, impacts, sweeps +# --------------------------------------------------------------------------- + +FX_ROLE = "fx" + +# Map: boundary section index → (type, start_offset, length, fade_in, fade_out) +# or list of tuples for boundaries with multiple FX (e.g. riser + impact). +# Position = offsets[boundary_idx] * 4 + start_offset +# Types: riser (before climax with long fade_in), impact (on downbeat with fade_out), +# sweep/transition (brief bridge, both fades) +FX_TRANSITIONS: dict[int, tuple | list[tuple]] = { + 2: ("sweep", -2, 2, 0.3, 0.0), # verse→pre-chorus (beat 48 → pos 46) + 3: [ # pre-chorus→chorus (beat 64) + ("riser", -4, 4, 1.5, 0.0), # pos 60 — builds into chorus + ("impact", 0, 2, 0.0, 0.3), # pos 64 — hits on chorus downbeat + ], + 4: ("transition", -2, 2, 0.2, 0.2), # chorus→verse2 (beat 96 → pos 94) + 5: ("riser", -4, 4, 1.0, 0.0), # verse2→chorus2 (beat 128 → pos 124) + 6: ("sweep", -2, 2, 0.2, 0.2), # chorus2→bridge (beat 160 → pos 158) + 7: ("riser", -4, 4, 1.0, 0.0), # bridge→final (beat 176 → pos 172) + 8: ("sweep", -2, 2, 0.3, 0.5), # final→outro (beat 208 → pos 206) +} + +# Section energy — which tracks play in each section type +TRACK_ACTIVITY: dict[str, dict[str, bool]] = { + "intro": {"drumloop": True, "perc": False, "bass": False, "chords": False, "lead": False, "clap": False, "pad": True}, + "verse": {"drumloop": True, "perc": False, "bass": True, "chords": True, "lead": False, "clap": False, "pad": False}, + "pre-chorus": {"drumloop": True, "perc": False, "bass": True, "chords": True, "lead": True, "clap": True, "pad": False}, + "chorus": {"drumloop": True, "perc": True, "bass": True, "chords": True, "lead": True, "clap": True, "pad": True}, + "verse2": {"drumloop": True, "perc": False, "bass": True, "chords": True, "lead": False, "clap": False, "pad": False}, + "chorus2": {"drumloop": True, "perc": True, "bass": True, "chords": True, "lead": True, "clap": True, "pad": True}, + "bridge": {"drumloop": True, "perc": False, "bass": False, "chords": False, "lead": True, "clap": False, "pad": True}, + "final": {"drumloop": True, "perc": True, "bass": True, "chords": True, "lead": True, "clap": True, "pad": True}, + "outro": {"drumloop": True, "perc": False, "bass": False, "chords": False, "lead": False, "clap": False, "pad": True}, +} + +# (velocity_mult, vol_mult) per section type +SECTION_MULTIPLIERS: dict[str, tuple[float, float]] = { + "intro": (0.6, 0.70), + "verse": (0.7, 0.85), + "pre-chorus": (0.85, 0.95), + "chorus": (1.0, 1.00), + "verse2": (0.7, 0.85), + "chorus2": (1.0, 1.00), + "bridge": (0.6, 0.75), + "final": (1.0, 1.00), + "outro": (0.4, 0.60), +} + # --------------------------------------------------------------------------- # Helpers @@ -180,16 +234,24 @@ def get_pentatonic(root: str, is_minor: bool, octave: int) -> list[int]: return [root_midi + i for i in intervals] -def make_plugin(registry_key: str, index: int) -> PluginDef: +def make_plugin(registry_key: str, index: int, role: str = "") -> PluginDef: if registry_key in PLUGIN_REGISTRY: display, path, uid = PLUGIN_REGISTRY[registry_key] - preset = PLUGIN_PRESETS.get(registry_key) - return PluginDef(name=registry_key, path=path, index=index, preset_data=preset) - return PluginDef(name=registry_key, path=registry_key, index=index) + preset = PLUGIN_PRESETS.get((registry_key, role)) or PLUGIN_PRESETS.get((registry_key, "")) + return PluginDef(name=registry_key, path=path, index=index, preset_data=preset, role=role) + return PluginDef(name=registry_key, path=registry_key, index=index, role=role) + + +def _section_active(section_name: str, role: str, activity: dict) -> bool: + """Return whether a track role is active in the given section type.""" + return activity.get(section_name, {}).get(role, False) def build_section_structure(): - sections = [SectionDef(name=n, bars=b, energy=e) for n, b, e, _ in SECTIONS] + sections = [] + for n, b, e, _ in SECTIONS: + vm, vol = SECTION_MULTIPLIERS.get(n, (1.0, 1.0)) + sections.append(SectionDef(name=n, bars=b, energy=e, velocity_mult=vm, vol_mult=vol)) offsets = [] off = 0.0 for sec in sections: @@ -198,6 +260,45 @@ def build_section_structure(): return sections, offsets +# --------------------------------------------------------------------------- +# Sidechain kick cache +# --------------------------------------------------------------------------- + +_kick_cache: dict[str, list[float]] = {} +_KICK_CONFIDENCE_THRESHOLD = 0.6 + +_CC11_DIP = 50 +_CC11_HOLD = 0.02 +_CC11_RELEASE = 0.18 + + +def _get_kick_cache(drumloop_paths: list[str], bpm: float) -> dict[str, list[float]]: + """Analyze drumloops, return {path: [kick_time_beats]}. + + Uses module-level _kick_cache for deduplication across calls. + Kicks are filtered by confidence >= _KICK_CONFIDENCE_THRESHOLD. + Time is converted from seconds to beats using bpm. + """ + from src.composer.drum_analyzer import DrumLoopAnalyzer + + for path in drumloop_paths: + if path in _kick_cache: + continue + try: + analyzer = DrumLoopAnalyzer(path) + analysis = analyzer.analyze() + kicks = [ + t for t in analysis.transients + if t.type == "kick" and t.confidence >= _KICK_CONFIDENCE_THRESHOLD + ] + beat_dur = 60.0 / bpm + _kick_cache[path] = [t.time / beat_dur for t in kicks] + except Exception: + _kick_cache[path] = [] + + return _kick_cache + + # --------------------------------------------------------------------------- # Track Builders # --------------------------------------------------------------------------- @@ -220,9 +321,12 @@ def build_drumloop_track(sections, offsets, seed: int = 0) -> TrackDef: filtrado_idx = 0 for section, sec_off in zip(sections, offsets): + # Skip sections where drumloop is not active + if not _section_active(section.name, "drumloop", TRACK_ACTIVITY): + continue + # Determine variant - section_key = section.name - variant = DRUMLOOP_ASSIGNMENTS.get(section_key, "seco") + variant = DRUMLOOP_ASSIGNMENTS.get(section.name, "seco") if variant == "empty": continue # no drumloop in this section @@ -241,9 +345,10 @@ def build_drumloop_track(sections, offsets, seed: int = 0) -> TrackDef: name=f"{section.name.capitalize()} Drumloop", audio_path=path, loop=True, + vol_mult=section.vol_mult, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("drumloop", []))] + plugins = [make_plugin(fx, i, role="drumloop") for i, fx in enumerate(FX_CHAINS.get("drumloop", []))] return TrackDef( name="Drumloop", volume=VOLUME_LEVELS["drumloop"], @@ -272,8 +377,8 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef: clips = [] for i, (section, sec_off) in enumerate(zip(sections, offsets)): - # Perc in verse and chorus only, not intro/break/outro - if section.name in ("intro", "break", "bridge", "outro"): + # Use centralized activity matrix instead of ad-hoc name check + if not _section_active(section.name, "perc", TRACK_ACTIVITY): continue perc_name = perc_files[i % len(perc_files)] @@ -286,9 +391,10 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef: name=f"{section.name.capitalize()} Perc", audio_path=str(perc_path), loop=True, + vol_mult=section.vol_mult, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("perc", []))] + plugins = [make_plugin(fx, i, role="perc") for i, fx in enumerate(FX_CHAINS.get("perc", []))] return TrackDef( name="Perc", volume=VOLUME_LEVELS["perc"], @@ -298,18 +404,25 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef: ) -def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> TrackDef: - """808 bass using PROVEN harmonic pattern from Ableton project.""" +def build_bass_track(sections, offsets, key_root: str, key_minor: bool, + kick_cache: dict[str, list[float]] | None = None) -> TrackDef: + """808 bass using PROVEN harmonic pattern from Ableton project. + + When kick_cache is provided, generates CC11 ducking events on kick hits. + """ root_midi = key_to_midi_root(key_root, 1) # Octave 1 for 808 # Transpose the Ableton pattern to match the project key # Ableton pattern is in Am (root=33=A1), transpose to project key transpose = root_midi - 33 # 33 is A1 from Ableton pattern + kick_cache = kick_cache or {} clips = [] for section, sec_off in zip(sections, offsets): - vm = section.energy - velocity = int(80 + 15 * vm) # 80-95 depending on energy + if not _section_active(section.name, "bass", TRACK_ACTIVITY): + continue + + velocity = int(80 * section.velocity_mult) notes = [] bars = section.bars @@ -329,15 +442,36 @@ def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> Track velocity=velocity, )) + # Generate CC11 ducking events from kick cache + cc_events: list[CCEvent] = [] + clip_start = sec_off * 4.0 + clip_end = clip_start + section.bars * 4.0 + + # Collect all kick positions in this clip's time range + clip_kicks: list[float] = [] + for drumloop_path, kicks in kick_cache.items(): + for kick_beat in kicks: + if clip_start <= kick_beat < clip_end: + clip_kicks.append((kick_beat - clip_start)) # relative to clip start + + clip_kicks.sort() + + for rel_kick in clip_kicks: + cc_events.append(CCEvent(controller=11, time=rel_kick, value=_CC11_DIP)) + cc_events.append(CCEvent(controller=11, time=rel_kick + _CC11_HOLD, value=_CC11_DIP)) + cc_events.append(CCEvent(controller=11, time=rel_kick + _CC11_RELEASE, value=127)) + if notes: clips.append(ClipDef( position=sec_off * 4.0, length=section.bars * 4.0, name=f"{section.name.capitalize()} 808", midi_notes=notes, + midi_cc=cc_events, + vol_mult=section.vol_mult, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("bass", []))] + plugins = [make_plugin(fx, i, role="bass") for i, fx in enumerate(FX_CHAINS.get("bass", []))] return TrackDef( name="808 Bass", volume=VOLUME_LEVELS["bass"], @@ -347,20 +481,30 @@ def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> Track ) -def build_chords_track(sections, offsets, key_root: str, key_minor: bool) -> TrackDef: - """Chords: i-VI-III-VII on downbeats, match key.""" - root_midi = key_to_midi_root(key_root, 3) +def build_chords_track( + sections, offsets, key_root: str, key_minor: bool, + emotion: str = "romantic", inversion: str = "root", +) -> TrackDef: + """Chords: delegate to ChordEngine for progression + voice leading.""" + key = key_root + "m" if key_minor else key_root + engine = ChordEngine(key, seed=42) + clips = [] for section, sec_off in zip(sections, offsets): if section.name in ("intro", "break", "outro"): continue # no chords in sparse sections vm = section.energy + voicings = engine.progression( + section.bars, emotion=emotion, + beats_per_chord=4, inversion=inversion, + ) + notes = [] for bar in range(section.bars): - ci = bar % len(CHORD_PROGRESSION) - interval, quality = CHORD_PROGRESSION[ci] - for pitch in build_chord(root_midi + interval, quality): + chord_idx = bar % len(voicings) + voicing = voicings[chord_idx] + for pitch in voicing: notes.append(MidiNote( pitch=pitch, start=bar * 4.0, @@ -375,7 +519,7 @@ def build_chords_track(sections, offsets, key_root: str, key_minor: bool) -> Tra midi_notes=notes, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("chords", []))] + plugins = [make_plugin(fx, i, role="chords") for i, fx in enumerate(FX_CHAINS.get("chords", []))] return TrackDef( name="Chords", volume=VOLUME_LEVELS["chords"], @@ -386,33 +530,26 @@ def build_chords_track(sections, offsets, key_root: str, key_minor: bool) -> Tra def build_lead_track(sections, offsets, key_root: str, key_minor: bool, seed: int = 0) -> TrackDef: - """Lead melody: pentatonic, sparse, chord tones on strong beats.""" - penta = get_pentatonic(key_root, key_minor, 4) + get_pentatonic(key_root, key_minor, 5) - rng = random.Random(seed) + """Lead melody: hook-based call-response using melody_engine. + + Replaces random pentatonic generation with deterministic motif engine + producing arch-contour hooks, chord-tone emphasis, and call-response phrasing. + """ clips = [] for section, sec_off in zip(sections, offsets): - # Lead only in chorus and final sections - if section.name not in ("chorus", "chorus2", "final"): + # Lead only in sections where the lead role is active + if not _section_active(section.name, "lead", TRACK_ACTIVITY): continue - vm = section.energy - density = 0.4 - notes = [] + # Build a hook motif for this section (4 bars), then expand to section length + motif = build_motif(key_root, key_minor, "hook", bars=min(4, section.bars), seed=seed) + notes = build_call_response(motif, bars=section.bars, key_root=key_root, + key_minor=key_minor, seed=seed + 1) - for bar in range(section.bars): - for sixteenth in range(16): - bp = bar * 4.0 + sixteenth * 0.25 - if rng.random() > density: - continue - strong = sixteenth in (0, 4, 8, 12) # quarter note positions - pool = [penta[0], penta[2], penta[4]] if strong else penta - notes.append(MidiNote( - pitch=rng.choice(pool), - start=bp, - duration=0.5 if strong else 0.25, - velocity=int((85 if strong else 65) * vm), - )) + # Scale velocities to section energy + for note in notes: + note.velocity = int(note.velocity * section.velocity_mult) if notes: clips.append(ClipDef( @@ -420,9 +557,10 @@ def build_lead_track(sections, offsets, key_root: str, key_minor: bool, seed: in length=section.bars * 4.0, name=f"{section.name.capitalize()} Lead", midi_notes=notes, + vol_mult=section.vol_mult, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("lead", []))] + plugins = [make_plugin(fx, i, role="lead") for i, fx in enumerate(FX_CHAINS.get("lead", []))] return TrackDef( name="Lead", volume=VOLUME_LEVELS["lead"], @@ -440,7 +578,7 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef: clips = [] if clap_path: for section, sec_off in zip(sections, offsets): - if not section.name.startswith(("chorus", "verse", "final")): + if not _section_active(section.name, "clap", TRACK_ACTIVITY): continue for bar in range(section.bars): for cb in CLAP_POSITIONS: @@ -449,9 +587,10 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef: length=0.5, name=f"{section.name.capitalize()} Clap", audio_path=clap_path, + vol_mult=section.vol_mult, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("clap", []))] + plugins = [make_plugin(fx, i, role="clap") for i, fx in enumerate(FX_CHAINS.get("clap", []))] return TrackDef( name="Clap", volume=VOLUME_LEVELS["clap"], @@ -461,6 +600,53 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef: ) +def build_fx_track( + sections, offsets, selector: SampleSelector, seed: int = 0, +) -> TrackDef: + """Build a dedicated transition FX track with audio clips at section boundaries. + + Uses SampleSelector.select_one(role="fx") to pick FX samples from the library. + FX_TRANSITIONS maps boundary section indices to (type, offset, length, fade_in, fade_out). + Boundary 3 (pre-chorus→chorus) has two entries: riser BEFORE the boundary and impact ON it. + + Clip positions are computed as: offsets[boundary_idx] * 4 + start_offset. + Risers have fade_in > 0 (build-up); impacts have fade_out > 0 (hit); sweeps have both. + """ + clips = [] + fx_idx = 0 + + for boundary_idx, entries in sorted(FX_TRANSITIONS.items()): + # Normalise: single tuple → list of tuples + items = [entries] if isinstance(entries, tuple) else entries + + for fx_type, start_offset, length, fade_in, fade_out in items: + boundary_beat = offsets[boundary_idx] * 4.0 + position = boundary_beat + start_offset + + # Select one FX sample per clip for variety + sample = selector.select_one(role=FX_ROLE, seed=seed + fx_idx) + fx_idx += 1 + + audio_path = sample.get("original_path") if sample else None + + clips.append(ClipDef( + position=position, + length=float(length), + name=f"{fx_type.capitalize()} FX", + audio_path=audio_path, + fade_in=float(fade_in), + fade_out=float(fade_out), + )) + + return TrackDef( + name="Transition FX", + volume=0.72, + pan=0.0, + clips=clips, + send_level={0: 0.08, 1: 0.05}, + ) + + def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackDef: """Pad: sustained root chord, only in chorus/build sections.""" root_midi = key_to_midi_root(key_root, 3) @@ -469,13 +655,13 @@ def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackD clips = [] for section, sec_off in zip(sections, offsets): - # Pad in build, chorus, bridge, final only - if section.name not in ("build", "chorus", "chorus2", "bridge", "final"): + # Pad only where the pad role is active + if not _section_active(section.name, "pad", TRACK_ACTIVITY): continue - vm = section.energy + velocity = int(55 * section.velocity_mult) notes = [ - MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(55 * vm)) + MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=velocity) for p in chord ] clips.append(ClipDef( @@ -483,9 +669,10 @@ def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackD length=section.bars * 4.0, name=f"{section.name.capitalize()} Pad", midi_notes=notes, + vol_mult=section.vol_mult, )) - plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("pad", []))] + plugins = [make_plugin(fx, i, role="pad") for i, fx in enumerate(FX_CHAINS.get("pad", []))] return TrackDef( name="Pad", volume=VOLUME_LEVELS["pad"], @@ -526,6 +713,20 @@ def main() -> None: parser.add_argument("--key", default="Am", help="Key (default: Am)") parser.add_argument("--output", default="output/song.rpp", help="Output path") parser.add_argument("--seed", type=int, default=None, help="Random seed") + parser.add_argument( + "--emotion", default="romantic", + choices=["romantic", "dark", "club", "classic"], + help="Emotion mode for chord progression (default: romantic)", + ) + parser.add_argument( + "--inversion", default="root", + choices=["root", "first", "second"], + help="Chord inversion preference (default: root)", + ) + parser.add_argument( + "--no-calibrate", action="store_true", + help="Skip post-processing mix calibration", + ) args = parser.parse_args() if args.seed is not None: @@ -552,14 +753,22 @@ def main() -> None: total_beats = sum(s.bars for s in sections) * 4.0 print(f"Sections: {len(sections)}, Total: {int(total_beats/4)} bars ({total_beats} beats)") + # Build kick cache from unique drumloop paths + drumloop_track = build_drumloop_track(sections, offsets, seed=args.seed or 0) + drumloop_paths = sorted({c.audio_path for c in drumloop_track.clips if c.audio_path}) + if drumloop_paths: + _get_kick_cache(drumloop_paths, bpm) + # Build tracks tracks = [ build_drumloop_track(sections, offsets, seed=args.seed or 0), build_perc_track(sections, offsets, seed=args.seed or 0), build_bass_track(sections, offsets, key_root, key_minor), - build_chords_track(sections, offsets, key_root, key_minor), + build_chords_track(sections, offsets, key_root, key_minor, + emotion=args.emotion, inversion=args.inversion), build_lead_track(sections, offsets, key_root, key_minor, seed=args.seed or 42), build_clap_track(selector, sections, offsets), + build_fx_track(sections, offsets, selector, seed=args.seed or 0), build_pad_track(sections, offsets, key_root, key_minor), ] @@ -577,7 +786,10 @@ def main() -> None: track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]} # Assemble - meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Instrumental") + meta = SongMeta( + bpm=bpm, key=key, title="Reggaeton Instrumental", + calibrate=not args.no_calibrate, + ) song = SongDefinition( meta=meta, tracks=all_tracks, @@ -585,6 +797,10 @@ def main() -> None: master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"], ) + # Post-processing mix calibration (unless --no-calibrate) + if meta.calibrate: + Calibrator.apply(song) + errors = song.validate() if errors: print("WARNING: validation errors:", file=sys.stderr) diff --git a/src/calibrator/__init__.py b/src/calibrator/__init__.py new file mode 100644 index 0000000..e7d9209 --- /dev/null +++ b/src/calibrator/__init__.py @@ -0,0 +1,185 @@ +"""Post-processing mix calibrator for SongDefinition. + +Applies role-based volume, EQ, pan, sends, and master chain upgrades +after track construction and before RPPBuilder. +""" + +from __future__ import annotations + +from ..core.schema import SongDefinition, TrackDef, PluginDef +from .presets import VOLUME_PRESETS, EQ_PRESETS, PAN_PRESETS, SEND_PRESETS + + +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). + """ + Calibrator._calibrate_volumes(song) + Calibrator._calibrate_eq(song) + Calibrator._calibrate_pans(song) + Calibrator._calibrate_sends(song) + Calibrator._swap_master_chain(song) + return song + + # ------------------------------------------------------------------ + # Role resolution + # ------------------------------------------------------------------ + + @staticmethod + def _resolve_role(track_name: str) -> str | None: + """Map track name to role key, or None if unrecognized. + + Uses substring matching to be tolerant of display names + like '808 Bass', 'Drumloop', etc. + """ + lower = track_name.strip().lower() + # Return tracks + if lower in ("reverb", "delay"): + return None + # Direct matches + if lower in ("drumloop", "chords", "lead", "clap", "pad", "perc"): + return lower + # Substring matches + if "bass" in lower: + return "bass" + if "drum" in lower: + return "drumloop" + if "perc" in lower: + return "perc" + if "clap" in lower or "snare" in lower: + return "clap" + if "chord" in lower: + return "chords" + if "lead" in lower or "synth" in lower or "melody" in lower: + return "lead" + if "pad" in lower or "atmos" in lower or "ambient" in lower: + return "pad" + return None + + # ------------------------------------------------------------------ + # Volume calibration + # ------------------------------------------------------------------ + + @staticmethod + def _calibrate_volumes(song: SongDefinition) -> None: + """Set track volumes from VOLUME_PRESETS by role. + + Skips return tracks and unknown roles. + """ + for track in song.tracks: + role = Calibrator._resolve_role(track.name) + if role is None: + continue + if role in VOLUME_PRESETS: + track.volume = VOLUME_PRESETS[role] + + # ------------------------------------------------------------------ + # Pan calibration + # ------------------------------------------------------------------ + + @staticmethod + def _calibrate_pans(song: SongDefinition) -> None: + """Set track pan from PAN_PRESETS by role. + + Skips return tracks and unknown roles. + """ + for track in song.tracks: + role = Calibrator._resolve_role(track.name) + if role is None: + continue + if role in PAN_PRESETS: + track.pan = PAN_PRESETS[role] + + # ------------------------------------------------------------------ + # Send calibration + # ------------------------------------------------------------------ + + @staticmethod + def _calibrate_sends(song: SongDefinition) -> None: + """Set send_level dict for reverb/delay from SEND_PRESETS. + + Reverb is at index N (number of content tracks), Delay at N+1. + Skips return tracks. + """ + # Count return tracks to compute correct indices + num_content = sum( + 1 for t in song.tracks + if Calibrator._resolve_role(t.name) is not None + ) + num_return = len(song.tracks) - num_content + reverb_idx = num_content + delay_idx = num_content + 1 + + for track in song.tracks: + role = Calibrator._resolve_role(track.name) + if role is None: + continue + if role in SEND_PRESETS: + rv, dy = SEND_PRESETS[role] + track.send_level[reverb_idx] = rv + track.send_level[delay_idx] = dy + + # ------------------------------------------------------------------ + # EQ calibration (ReaEQ injection) + # ------------------------------------------------------------------ + + @staticmethod + def _calibrate_eq(song: SongDefinition) -> None: + """Prepend a ReaEQ PluginDef with HPF/LPF params to each non-return track. + + Skips tracks without a recognized role (unknown roles keep existing plugins). + """ + from ..reaper_builder import PLUGIN_REGISTRY + + reaeq_entry = PLUGIN_REGISTRY.get("ReaEQ") + reaeq_path = reaeq_entry[1] if reaeq_entry else "reaeq.dll" + + for track in song.tracks: + role = Calibrator._resolve_role(track.name) + if role is None: + continue + if role not in EQ_PRESETS: + continue + + eq_params = EQ_PRESETS[role] + reaeq = PluginDef( + name="ReaEQ", + path=reaeq_path, + index=0, + params=dict(eq_params), + ) + # Rewrite indices and prepend + for p in track.plugins: + p.index += 1 + track.plugins.insert(0, reaeq) + + # ------------------------------------------------------------------ + # Master chain upgrade + # ------------------------------------------------------------------ + + @staticmethod + def _swap_master_chain(song: SongDefinition) -> None: + """Replace master_plugins with Ozone 12 triplet. + + Falls back to Pro-Q_3/Pro-C_2/Pro-L_2 if any Ozone plugin is + missing from PLUGIN_REGISTRY. + """ + from ..reaper_builder import PLUGIN_REGISTRY + + ozone_triplet = [ + "Ozone_12_Equalizer", + "Ozone_12_Dynamics", + "Ozone_12_Maximizer", + ] + fallback_triplet = ["Pro-Q_3", "Pro-C_2", "Pro-L_2"] + + # Check Ozone availability + all_ozone_available = all(k in PLUGIN_REGISTRY for k in ozone_triplet) + + song.master_plugins = ozone_triplet if all_ozone_available else fallback_triplet diff --git a/src/calibrator/presets.py b/src/calibrator/presets.py new file mode 100644 index 0000000..9c32d7f --- /dev/null +++ b/src/calibrator/presets.py @@ -0,0 +1,63 @@ +"""Preset tables for automated mix calibration. + +All presets are keyed by track role (name → role mapping via _resolve_role()). +""" + +# --------------------------------------------------------------------------- +# Volume presets (0.0–1.0 REAPER volume) +# --------------------------------------------------------------------------- + +VOLUME_PRESETS: dict[str, float] = { + "drumloop": 0.85, + "bass": 0.82, + "chords": 0.75, + "lead": 0.80, + "clap": 0.78, + "pad": 0.70, + "perc": 0.80, +} + +# --------------------------------------------------------------------------- +# EQ presets — ReaEQ VST2 param slot → value +# Slot 0: band enabled (1=on) +# Slot 1: filter type (0=LPF, 1=HPF) +# Slot 2: frequency (Hz) +# --------------------------------------------------------------------------- + +EQ_PRESETS: dict[str, dict[int, float]] = { + "drumloop": {0: 1, 1: 1, 2: 60.0}, # HPF 60Hz + "bass": {0: 1, 1: 0, 2: 300.0}, # LPF 300Hz + "chords": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz + "lead": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz + "clap": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz + "pad": {0: 1, 1: 1, 2: 100.0}, # HPF 100Hz + "perc": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz +} + +# --------------------------------------------------------------------------- +# Pan presets (-1.0 to 1.0) +# --------------------------------------------------------------------------- + +PAN_PRESETS: dict[str, float] = { + "drumloop": 0.0, + "bass": 0.0, + "chords": 0.5, + "lead": 0.3, + "clap": -0.15, + "pad": -0.5, + "perc": 0.12, +} + +# --------------------------------------------------------------------------- +# Send presets — (reverb_send, delay_send) tuples (0.0–1.0) +# --------------------------------------------------------------------------- + +SEND_PRESETS: dict[str, tuple[float, float]] = { + "drumloop": (0.10, 0.00), + "bass": (0.05, 0.00), + "chords": (0.40, 0.10), + "lead": (0.30, 0.15), + "clap": (0.10, 0.00), + "pad": (0.50, 0.20), + "perc": (0.10, 0.00), +} diff --git a/src/composer/chords.py b/src/composer/chords.py new file mode 100644 index 0000000..d66562d --- /dev/null +++ b/src/composer/chords.py @@ -0,0 +1,217 @@ +"""Smart chord progression engine with voice leading and emotion-aware patterns. + +Deterministic: same (key, seed) → same output every time. +Uses random.Random(seed) as tiebreaker in voice leading for variation. +""" + +from __future__ import annotations + +import random +from src.composer import CHORD_TYPES + +# --------------------------------------------------------------------------- +# Emotion → progression mapping +# Each entry: list of (semitone_offset_from_tonic, chord_quality) tuples. +# 4-chord loops designed for reggaeton / Latin pop. +# --------------------------------------------------------------------------- + +EMOTION_PROGRESSIONS: dict[str, list[tuple[int, str]]] = { + "romantic": [(0, "min"), (8, "maj"), (3, "maj"), (10, "maj")], # i-VI-III-VII + "dark": [(0, "min"), (5, "min"), (10, "maj"), (3, "maj")], # i-iv-VII-III + "club": [(0, "min"), (8, "maj"), (10, "maj"), (7, "maj")], # i-VI-VII-V + "classic": [(0, "min"), (10, "maj"), (8, "maj"), (7, "maj")], # i-VII-VI-V +} + + +class ChordEngine: + """Generates chord progressions with voice leading. + + Deterministc: same (key, seed) produces identical voicing choices. + Uses random.Random as a micro-tiebreaker in voice-leading selection + so different seeds *can* produce divergent voicings. + """ + + NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + + def __init__(self, key: str, seed: int = 42) -> None: + self.key = key + self._seed = seed + + # Parse key into tonic name + minor flag + if key.endswith("m"): + self._tonic_name = key[:-1] + self._is_minor = True + else: + self._tonic_name = key + self._is_minor = False + + # Tonic MIDI number at octave 3 (ideal for chord voicings) + base = self.NOTE_NAMES.index(self._tonic_name) + self._tonic = (3 + 1) * 12 + base # octave 3 = 48 + base + + self._rng = random.Random(seed) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def progression( + self, + bars: int, + emotion: str = "classic", + beats_per_chord: int = 4, + inversion: str = "root", + ) -> list[list[int]]: + """Generate chord progression with voice leading. + + Args: + bars: Total bars. 0 → empty list. + emotion: Romantic / dark / club / classic (unknown → classic). + beats_per_chord: Duration of each chord in beats (default 4). + inversion: Preferred inversion for first chord (root/first/second). + + Returns: + List of voicings — each voicing is a list[int] of MIDI notes. + """ + if bars <= 0: + return [] + + # Reset RNG for deterministic reproducibility across calls. + self._rng = random.Random(self._seed) + + degrees = self._get_degrees(emotion) + + # Build root-position chords from degree offsets + quality. + # Wrap root notes to stay within ±6 semitones of the tonic octave + # so voice-leading candidates are in the same register. + chords: list[list[int]] = [] + for offset, quality in degrees: + root = self._tonic + offset + while root > self._tonic + 6: + root -= 12 + while root < self._tonic - 6: + root += 12 + intervals = CHORD_TYPES.get(quality, [0, 4, 7]) + chords.append([root + iv for iv in intervals]) + + total_beats = bars * 4 + num_chords = total_beats // beats_per_chord + + # Cycle the progression to fill the requested bars. + full_sequence: list[list[int]] = [] + for i in range(num_chords): + full_sequence.append(chords[i % len(chords)]) + + # Apply voice leading to the full sequence. + return self._voice_leading(full_sequence, inversion) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _get_degrees(self, emotion: str) -> list[tuple[int, str]]: + """Resolve emotion → list of (offset, quality) tuples. + + Unknown emotions silently fall back to "classic". + """ + return EMOTION_PROGRESSIONS.get(emotion, EMOTION_PROGRESSIONS["classic"]) + + def _apply_inversion(self, voicing: list[int], inversion: str) -> list[int]: + """Reorder notes so target pitch is the lowest. + + Args: + voicing: Sorted MIDI notes [root, third, fifth, ...]. + inversion: "root" (identity), "first" (third in bass), + "second" (fifth in bass). + + Returns: + New list where the target note is the lowest pitch. + """ + if not voicing or inversion == "root": + return list(voicing) + + if inversion == "first": + # Third becomes lowest; root goes up an octave. + return voicing[1:] + [voicing[0] + 12] + + if inversion == "second": + # Fifth becomes lowest; root and third go up an octave. + return voicing[2:] + [v + 12 for v in voicing[:2]] + + # Unknown inversion → identity. + return list(voicing) + + def _score_voicing(self, prev: list[int], cand: list[int]) -> float: + """Sum of absolute semitone differences + micro-jitter for tiebreaking. + + The micro-jitter (rng × 0.1) lets different seeds produce different + voicing choices when two candidates are nearly tied. + """ + base = sum(abs(c - p) for c, p in zip(cand, prev)) + return base + self._rng.uniform(0, 0.1) + + def _voice_leading( + self, chords: list[list[int]], inversion: str = "root" + ) -> list[list[int]]: + """Greedy min-score path through chord voicings. + + For each chord: + 1. Build candidates: root-position + first + second inversions (sorted). + 2. Score each candidate against the previous voicing. + 3. Filter candidates where every voice moves ≤ 4 semitones. + 4. Pick the lowest-scoring candidate (greedy). + 5. If no candidate passes the filter, keep the root-position voicing. + """ + if not chords: + return [] + + voicings: list[list[int]] = [] + prev: list[int] | None = None + + for chord in chords: + # Build all candidates: root + inversions at native octave + # AND one octave down — so voice leading can reach smooth paths + # even when the next chord's root is far from the tonic. + candidates: list[list[int]] = [] + for oct_shift in (0, -12): + shifted = [n + oct_shift for n in chord] + candidates.append(sorted(shifted)) # root position + if len(chord) >= 3: + candidates.append( + sorted(self._apply_inversion(shifted, "first")) + ) + candidates.append( + sorted(self._apply_inversion(shifted, "second")) + ) + + if prev is None: + # First chord: use the caller's preferred inversion + # at the native octave (candidates[0]=root, [1]=first, [2]=second). + if inversion == "first" and len(candidates) >= 2: + best = candidates[1] + elif inversion == "second" and len(candidates) >= 3: + best = candidates[2] + else: + best = candidates[0] + else: + best = None + best_score = float("inf") + + for cand in candidates: + # Hard cap: every voice ≤ 4 semitones. + if any(abs(c - p) > 4 for c, p in zip(cand, prev)): + continue + + score = self._score_voicing(prev, cand) + if score < best_score: + best_score = score + best = cand + + # Fallback: no candidate passed the filter → root, native octave. + if best is None: + best = candidates[0] + + voicings.append(best) + prev = best + + return voicings diff --git a/src/composer/melody_engine.py b/src/composer/melody_engine.py new file mode 100644 index 0000000..6be8b5b --- /dev/null +++ b/src/composer/melody_engine.py @@ -0,0 +1,508 @@ +"""Hook-based reggaeton melody engine — deterministic, chord-aware motif generation. + +Replaces random pentatonic lead lines with structured repeating motifs +using call-and-response phrasing and arch-contour hooks. + +Design: pure functions, no I/O, no global state. +RNG: random.Random(seed) per-call for full determinism. +""" + +from __future__ import annotations + +import random +from src.core.schema import MidiNote + +# --------------------------------------------------------------------------- +# Musical constants +# --------------------------------------------------------------------------- + +_NOTE_TO_MIDI = { + "C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, + "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11, +} + +# i-VI-III-VII progression (reggaeton standard) — duplicated from compose.py +_CHORD_PROGRESSION: list[tuple[int, str]] = [ + (0, "minor"), # i + (8, "major"), # VI + (3, "major"), # III + (10, "major"), # VII +] + +_CHORD_BARS = 2 # each chord lasts 2 bars + +# Dembow stab grid (beat offsets within each bar) +_DEMBOW_POSITIONS: list[float] = [1.0, 2.5, 3.0, 3.5] + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _midi_for_name(note_name: str, octave: int = 4) -> int: + """Convert note name (e.g. "A") to MIDI pitch at given octave.""" + return (octave + 1) * 12 + _NOTE_TO_MIDI[note_name] + + +def _get_pentatonic(key_root: str, key_minor: bool, octave: int) -> list[int]: + """Return pentatonic scale MIDI notes for the given key and octave.""" + root_midi = _midi_for_name(key_root, octave) + intervals = [0, 3, 5, 7, 10] if key_minor else [0, 2, 4, 7, 9] + return [root_midi + i for i in intervals] + + +def _get_diatonic(key_root: str, key_minor: bool, octave: int) -> list[int]: + """Return full diatonic scale MIDI notes (7 notes, not pentatonic).""" + root_midi = _midi_for_name(key_root, octave) + intervals = [0, 2, 3, 5, 7, 8, 10] if key_minor else [0, 2, 4, 5, 7, 9, 11] + return [root_midi + i for i in intervals] + + +def _resolve_chord_tones( + key_root: str, key_minor: bool, bar: int, octave: int = 4, +) -> set[int]: + """Return MIDI pitches for the active chord at a given bar index. + + Cycles through i-VI-III-VII, each chord lasting _CHORD_BARS. + Includes chord tones across ±1 octave range for flexible voicing. + """ + tonic_midi = _midi_for_name(key_root, octave) + chord_idx = (bar // _CHORD_BARS) % len(_CHORD_PROGRESSION) + offset, quality = _CHORD_PROGRESSION[chord_idx] + root = tonic_midi + offset + + if quality == "minor": + intervals = [0, 3, 7] + else: + intervals = [0, 4, 7] + + tones: set[int] = set() + for oct_shift in (-12, 0, 12): + for iv in intervals: + tones.add(root + iv + oct_shift) + return tones + + +def _resolve_tension_notes( + key_root: str, key_minor: bool, octave: int = 4, +) -> tuple[int, int]: + """Return (V, VII) MIDI pitches for call-resolution scheme.""" + tonic_midi = _midi_for_name(key_root, octave) + v_pitch = tonic_midi + 7 # perfect fifth + # VII: minor = whole step below tonic, major = half step below + vii_pitch = tonic_midi + (10 if key_minor else 11) + return v_pitch, vii_pitch + + +def _resolve_tonic(key_root: str, octave: int = 4) -> int: + """Return tonic MIDI pitch.""" + return _midi_for_name(key_root, octave) + + +def _quantize_to_scale(pitch: int, scale_notes: list[int]) -> int: + """Snap pitch to the nearest scale note (by semitone distance).""" + if not scale_notes: + return pitch + return min(scale_notes, key=lambda s: abs(s - pitch)) + + +# --------------------------------------------------------------------------- +# Style-specific motif builders +# --------------------------------------------------------------------------- + +def _build_hook( + key_root: str, key_minor: bool, bars: int, rng: random.Random, +) -> list[MidiNote]: + """Hook style: arch contour, chord tones on quarter-beat positions. + + Each bar gets 4 chord-tone notes forming a low→mid→high→mid arch, + plus an optional off-beat ghost note for reggaeton feel. + All quarter-position notes are chord tones (100% ratio). + + Across bars, the peak pitch shifts to follow the chord progression, + creating a natural melodic arc. + """ + scale_oct4 = _get_pentatonic(key_root, key_minor, 4) + scale_oct5 = _get_pentatonic(key_root, key_minor, 5) + all_scale = sorted(set(scale_oct4 + scale_oct5)) + + notes: list[MidiNote] = [] + + for bar in range(bars): + chord_tones = _resolve_chord_tones(key_root, key_minor, bar, 4) + # Filter to chord tones that align with the scale + chord_in_scale = sorted(set( + p for p in chord_tones + if any(abs(p % 12 - s % 12) == 0 for s in all_scale) + )) + if len(chord_in_scale) < 4: + # Not enough scale-aligned chord tones; use all chord tones + chord_in_scale = sorted(chord_tones) + + bar_start = bar * 4.0 + + # Build arch: pick 4 distinct chord-tone pitches + # low → mid-low → high → mid-high (arch shape) + n = len(chord_in_scale) + if n >= 4: + low = chord_in_scale[0] + mid_low = chord_in_scale[n // 3] + high = chord_in_scale[-1] + mid_high = chord_in_scale[2 * n // 3] + elif n == 3: + low, mid_low, high = chord_in_scale[0], chord_in_scale[1], chord_in_scale[2] + mid_high = chord_in_scale[1] # reuse mid + elif n == 2: + low = chord_in_scale[0] + mid_low = chord_in_scale[1] + high = chord_in_scale[0] + 12 # octave up + mid_high = chord_in_scale[1] + else: + low = mid_low = high = mid_high = chord_in_scale[0] + + # Ensure high > low for true arch shape + if high <= low + 2: + high = low + 12 if low + 12 in chord_tones else high + 7 + if mid_high <= mid_low: + mid_high = mid_low + 5 + + contour = [ + (0.0, low, 0.75), # beat 1 — low chord tone + (1.0, mid_low, 0.5), # beat 2 — walking up + (2.0, high, 0.75), # beat 3 — peak (chord tone) + (3.0, mid_high, 0.5), # beat 4 — coming down + ] + + for pos, pitch, dur in contour: + velocity = 85 if pos in (0.0, 2.0) else 70 + notes.append(MidiNote( + pitch=pitch, + start=bar_start + pos, + duration=dur, + velocity=velocity, + )) + + # Ghost note on beat 2.5 (reggaeton syncopation) — deterministically varied + if rng.random() < 0.6: + ghost_pitch = _quantize_to_scale( + mid_low + rng.choice([-2, 0, 2, 5]), all_scale, + ) + notes.append(MidiNote( + pitch=ghost_pitch, + start=bar_start + 1.5, + duration=0.25, + velocity=rng.randint(45, 60), + )) + + # Sort by start time so contour notes come before ghost notes + notes.sort(key=lambda n: n.start) + return notes + + +def _build_stabs( + key_root: str, key_minor: bool, bars: int, rng: random.Random, +) -> list[MidiNote]: + """Stabs style: short 16th-duration hits on dembow grid positions. + + All notes are on [1.0, 2.5, 3.0, 3.5] per bar with duration ≤ 0.25. + Strong positions (2.5, 3.5) favor chord tones. + """ + scale_oct4 = _get_pentatonic(key_root, key_minor, 4) + scale_oct5 = _get_pentatonic(key_root, key_minor, 5) + all_scale = sorted(set(scale_oct4 + scale_oct5)) + + notes: list[MidiNote] = [] + + for bar in range(bars): + chord_tones = _resolve_chord_tones(key_root, key_minor, bar, 4) + chord_in_scale = sorted(set( + p for p in chord_tones + if any(abs(p % 12 - s % 12) == 0 for s in all_scale) + )) + if not chord_in_scale: + chord_in_scale = sorted(chord_tones) + width = len(chord_in_scale) + + bar_start = bar * 4.0 + for pos_offset in _DEMBOW_POSITIONS: + is_strong = pos_offset in (2.5, 3.5) + + if is_strong and width > 0: + idx = rng.randint(0, width - 1) + idx = ((bar + int(pos_offset * 4)) * 7 + 3) % width # deterministic + pitch = chord_in_scale[idx] + else: + pitch = rng.choice(all_scale) + + notes.append(MidiNote( + pitch=pitch, + start=bar_start + pos_offset, + duration=0.25, # 16th note + velocity=80 if is_strong else 65, + )) + + return notes + + +def _build_smooth( + key_root: str, key_minor: bool, bars: int, rng: random.Random, +) -> list[MidiNote]: + """Smooth style: stepwise scalar eighth-note motion. + + Uses the full diatonic scale (not pentatonic) so consecutive + notes differ by ≤ 2 semitones. Melody walks through the scale + with occasional direction changes for contour. + """ + scale_oct4 = _get_diatonic(key_root, key_minor, 4) + scale_oct5 = _get_diatonic(key_root, key_minor, 5) + scale = sorted(set(scale_oct4 + scale_oct5)) + + if not scale: + return [] + + notes: list[MidiNote] = [] + total_eighths = bars * 8 + + # Start near tonic with seed-dependent offset for variation + tonic = _resolve_tonic(key_root, 4) + tonic_candidates = [s for s in scale if abs(s - tonic) <= 4] + if tonic_candidates: + current = rng.choice(tonic_candidates) if len(tonic_candidates) > 1 else tonic_candidates[0] + else: + current = tonic + direction = 1 # 1 = ascending, -1 = descending + + for ei in range(total_eighths): + pos = ei * 0.5 + bar = ei // 8 + + # Strong beats: bias toward chord tones + is_quarter = (ei % 4 == 0) + chord_tones = _resolve_chord_tones(key_root, key_minor, bar, 4) + + # Find candidates: scale notes within ±2 semitones of current + candidates = [s for s in scale if abs(s - current) <= 2 and s != current] + + if not candidates: + # No valid stepwise candidate; stay or jump small + candidates = [s for s in scale if abs(s - current) <= 3] + + if not candidates: + candidates = [current] + + # On quarter beats, prefer chord tones that are also stepwise-valid + if is_quarter: + chord_candidates = [c for c in candidates if c in chord_tones] + if chord_candidates: + candidates = chord_candidates + + # Occasionally reverse direction for seed-dependent variation + if rng.random() < 0.15: + direction *= -1 + + # Pick: prefer candidates in the current direction + if direction > 0: + up_candidates = [c for c in candidates if c > current] + if up_candidates: + next_pitch = rng.choice(up_candidates) if len(up_candidates) > 1 else up_candidates[0] + else: + down_candidates = [c for c in candidates if c < current] + if down_candidates: + next_pitch = rng.choice(down_candidates) if len(down_candidates) > 1 else down_candidates[-1] + else: + next_pitch = rng.choice(candidates) if len(candidates) > 1 else candidates[0] + direction = -1 + else: + down_candidates = [c for c in candidates if c < current] + if down_candidates: + next_pitch = rng.choice(down_candidates) if len(down_candidates) > 1 else down_candidates[-1] + else: + up_candidates = [c for c in candidates if c > current] + if up_candidates: + next_pitch = rng.choice(up_candidates) if len(up_candidates) > 1 else up_candidates[0] + else: + next_pitch = rng.choice(candidates) if len(candidates) > 1 else candidates[0] + direction = 1 + + # Clamp within overall range (don't go too high or low) + if next_pitch > scale[-3]: + direction = -1 + elif next_pitch < scale[3]: + direction = 1 + + velocity = 75 if is_quarter else 60 + notes.append(MidiNote( + pitch=next_pitch, + start=pos, + duration=0.5, + velocity=velocity, + )) + current = next_pitch + + return notes + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def build_motif( + key_root: str, + key_minor: bool, + style: str, + bars: int = 4, + seed: int = 42, +) -> list[MidiNote]: + """Generate a repeating motif using chord-aware scale selection. + + Args: + key_root: Tonic note name (e.g. "A", "D", "F#"). + key_minor: True for minor key, False for major. + style: One of "hook", "stabs", "smooth". + bars: Number of bars (2-8, clamped if outside). + seed: RNG seed for deterministic output. + + Returns: + List of MidiNote objects. + + Raises: + ValueError: If style is not one of "hook", "stabs", "smooth". + """ + valid = ("hook", "stabs", "smooth") + if style not in valid: + raise ValueError( + f"Invalid style '{style}'. Valid styles: {', '.join(valid)}" + ) + + # Clamp bars to safe range + bars = max(2, min(8, bars)) + + rng = random.Random(seed) + + if style == "hook": + return _build_hook(key_root, key_minor, bars, rng) + elif style == "stabs": + return _build_stabs(key_root, key_minor, bars, rng) + else: + return _build_smooth(key_root, key_minor, bars, rng) + + +def apply_variation( + motif: list[MidiNote], + shift_beats: float = 0.0, + transpose_semitones: int = 0, +) -> list[MidiNote]: + """Apply rhythmic shift and/or pitch transpose to a motif. + + Returns a new list; the original motif is unchanged. + All inter-onset intervals and durations are preserved. + + Args: + motif: Source notes. + shift_beats: Beat offset added to all start times. + transpose_semitones: Semitone offset added to all pitches. + + Returns: + New list of MidiNote objects. + """ + return [ + MidiNote( + pitch=note.pitch + transpose_semitones, + start=note.start + shift_beats, + duration=note.duration, + velocity=note.velocity, + ) + for note in motif + ] + + +def build_call_response( + motif: list[MidiNote], + bars: int = 8, + key_root: str = "A", + key_minor: bool = True, + seed: int = 42, +) -> list[MidiNote]: + """Build call-and-response structure from a motif. + + First half (call): motif repeated, last note forced to V or VII (tension). + Second half (response): motif repeated, last note forced to tonic i (resolution). + + The motif is repeated as needed to fill the section. + + Args: + motif: Source motif notes. + bars: Total bars for the call-response section. + key_root: Tonic note name. + key_minor: True for minor key. + seed: RNG seed. + + Returns: + List of MidiNote objects spanning `bars` worth of beats. + """ + if not motif: + return [] + + rng = random.Random(seed) + half_bars = bars // 2 + total_beats = bars * 4.0 + half_beats = half_bars * 4.0 + + # Determine motif length in beats (end of last note) + motif_end = max(n.start + n.duration for n in motif) + motif_bars = max(1, int((motif_end + 3.999) / 4.0)) + + repeats_per_half = max(1, half_bars // motif_bars) + + v_tonic, vii_tonic = _resolve_tension_notes(key_root, key_minor, 4) + tonic = _resolve_tonic(key_root, 4) + + result: list[MidiNote] = [] + + # --- Call half --- + for r in range(repeats_per_half): + offset = r * motif_bars * 4.0 + for note in motif: + new_start = note.start + offset + if new_start >= half_beats: + continue # skip notes beyond call half + result.append(MidiNote( + pitch=note.pitch, + start=new_start, + duration=note.duration, + velocity=note.velocity, + )) + + # Sort by start time so last-by-position = last in time + result.sort(key=lambda n: n.start) + + # Force last note of call half to tension pitch + call_notes = [n for n in result if n.start < half_beats] + if call_notes: + tension_pitch = v_tonic if rng.random() < 0.5 else vii_tonic + call_notes[-1].pitch = tension_pitch + + # --- Response half --- + response_start = half_beats + for r in range(repeats_per_half): + offset = response_start + r * motif_bars * 4.0 + for note in motif: + new_start = note.start + offset + if new_start >= total_beats: + continue # skip notes beyond section + result.append(MidiNote( + pitch=note.pitch, + start=new_start, + duration=note.duration, + velocity=min(127, note.velocity + 5), + )) + + # Sort again after adding response notes + result.sort(key=lambda n: n.start) + + # Force last note to tonic + response_notes = [n for n in result if n.start >= response_start] + if response_notes: + response_notes[-1].pitch = tonic + + return result diff --git a/src/composer/templates.py b/src/composer/templates.py index 7ab17fe..f5c626e 100644 --- a/src/composer/templates.py +++ b/src/composer/templates.py @@ -207,12 +207,13 @@ def _make_plugin_template( if entry: display_name, filename, uid_guid = entry - preset_data = PLUGIN_PRESETS.get(resolved_name) if is_vst2 else PLUGIN_PRESETS.get(resolved_name) + preset_data = PLUGIN_PRESETS.get((resolved_name, "")) else: # Unresolved — use name/path as display name with empty GUID display_name = f"VST3: {resolved_name}" if not is_vst2 else f"VST: {resolved_name}" filename = resolved_path uid_guid = "" + preset_data = None return PluginTemplate( name=resolved_name, @@ -378,7 +379,7 @@ def _parse_vst_block(lines: list[str], start_idx: int) -> tuple[PluginTemplate | or all(pl.strip() in ("0 0", "0", "") for pl in preset_lines) ) if is_fake_preset and registry_key: - registry_preset = PLUGIN_PRESETS.get(registry_key) + registry_preset = PLUGIN_PRESETS.get((registry_key, "")) if registry_preset: preset_lines = registry_preset diff --git a/src/core/schema.py b/src/core/schema.py index 5e7f2f9..4dcdf2b 100644 --- a/src/core/schema.py +++ b/src/core/schema.py @@ -32,6 +32,22 @@ class SongMeta: ppq: int = 960 # ticks per quarter note (REAPER default) time_sig_num: int = 4 # numerator e.g. 4 time_sig_den: int = 4 # denominator e.g. 4 + calibrate: bool = True # enable post-processing mix calibration + + +@dataclass +class CCEvent: + """A MIDI CC event within a clip. + + Attributes: + controller: CC number (e.g. 11 = Expression) + time: Position in beats from clip start + value: 0–127 + """ + + controller: int + time: float + value: int @dataclass @@ -121,9 +137,11 @@ class ClipDef: name: str = "" audio_path: str | None = None # for audio clips midi_notes: list[MidiNote] = field(default_factory=list) # for MIDI clips + midi_cc: list[CCEvent] = field(default_factory=list) # MIDI CC events loop: bool = False fade_in: float = 0.0 fade_out: float = 0.0 + vol_mult: float = 1.0 @property def is_midi(self) -> bool: @@ -150,6 +168,7 @@ class PluginDef: index: int = 0 params: dict[int, float] = field(default_factory=dict) preset_data: list[str] | None = None + role: str = "" # track role for role-aware preset lookup (e.g. "bass", "lead", "pad") @dataclass diff --git a/src/reaper_builder/__init__.py b/src/reaper_builder/__init__.py index 157c791..53381d4 100644 --- a/src/reaper_builder/__init__.py +++ b/src/reaper_builder/__init__.py @@ -799,7 +799,8 @@ ALIAS_MAP: dict[str, str] = { } # Auto-generated preset data from output/all_plugins_v2.rpp -PLUGIN_PRESETS: dict[str, list[str]] = { +# Flat source dict — transformed to role-aware {(plugin, role): chunks} below. +_PRESETS_FLAT: dict[str, list[str]] = { "Arcade": [ "AjncLu5e7f4AAAAAAgAAAAEAAAAAAAAAAgAAAAAAAAAy0wEAAQAAAP//AAA=", "ItMBAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAB0wpGQkNoAAAAAkFSQ0QAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", @@ -1502,6 +1503,36 @@ PLUGIN_PRESETS: dict[str, list[str]] = { ], } +# --------------------------------------------------------------------------- +# Role-aware preset lookup +# --------------------------------------------------------------------------- +# PLUGIN_PRESETS is restructured to {(plugin, role): chunks}. +# The "" (empty string) role is the default/fallback — the original unmodified +# preset for backward compatibility. Role-specific entries are derived via +# PresetTransformer.derive() (MVP: copies of default for now). + +PLUGIN_PRESETS: dict[tuple[str, str], list[str]] = {} +for _plugin_key, _chunks in _PRESETS_FLAT.items(): + PLUGIN_PRESETS[(_plugin_key, "")] = _chunks + +# Multi-role plugins — derive role variants (MVP: same as default) +from .preset_transformer import PresetTransformer + +_MULTI_ROLE_PLUGINS: dict[str, list[str]] = { + "Serum_2": ["bass", "lead"], + "Decapitator": ["drumloop", "bass", "clap", "perc"], + "Omnisphere": ["chords", "pad"], +} + +for _plugin, _roles in _MULTI_ROLE_PLUGINS.items(): + _default_chunks = PLUGIN_PRESETS.get((_plugin, "")) + if _default_chunks: + for _role in _roles: + # MVP: PresetTransformer.derive() returns default chunks unchanged + PLUGIN_PRESETS[(_plugin, _role)] = PresetTransformer.derive( + _plugin, _default_chunks, _role + ) + # --------------------------------------------------------------------------- # VST element builder (unified, handles both VST2 and VST3) @@ -1574,6 +1605,23 @@ def _make_guid() -> str: return str(uuid.UUID(bytes=bytes(random.getrandbits(8) for _ in range(16)), version=4)).upper() +def _resolve_preset(key: str, role: str, fallback: list[str] | None = None) -> list[str] | None: + """Resolve role-aware preset data from PLUGIN_PRESETS. + + Lookup chain: (key, role) → (key, "") → fallback → None. + The "" role is the default/backward-compatible entry. + """ + # Try role-specific first + chunks = PLUGIN_PRESETS.get((key, role)) + if chunks is not None: + return chunks + # Fall back to default (empty role) + chunks = PLUGIN_PRESETS.get((key, "")) + if chunks is not None: + return chunks + return fallback + + # --------------------------------------------------------------------------- # RPPBuilder # --------------------------------------------------------------------------- @@ -1747,7 +1795,34 @@ class RPPBuilder: and appends preset data lines as string children. Handles both VST2 (.dll with <> GUID) and VST3 (.vst3 with {} GUID). + When plugin.params is non-empty for a built-in VST2, bypasses registry + lookup and populates param_slots instead. """ + # Built-in VST2 plugins (ReaEQ, ReaComp, etc.) — .dll format + dll_map = { + "ReaEQ": "reaeq.dll", + "ReaComp": "reacomp.dll", + "ReaVerbate": "reaverbate.dll", + "ReaDelay": "readelay.dll", + "ReaCast": "reacast.dll", + "ReaFIR": "reafir.dll", + "ReaGate": "reagate.dll", + "ReaLimit": "realimit.dll", + "ReaPitch": "reapitch.dll", + "ReaVerb": "reaverb.dll", + "ReaXcomp": "reaxcomp.dll", + } + + # If the plugin has explicit params AND is a known built-in VST2, + # use the built-in path with populated param slots. + if plugin.params and plugin.name in dll_map: + dll_name = dll_map[plugin.name] + param_slots = ["0"] * 19 + for idx, val in plugin.params.items(): + if 0 <= idx < 19: + param_slots[idx] = str(val) + return Element("VST", [plugin.name, dll_name, "0", "", *param_slots]) + # Resolve alias if needed resolved_name = ALIAS_MAP.get(plugin.name, plugin.name) @@ -1763,24 +1838,10 @@ class RPPBuilder: if entry: display_name, filename, uid_guid = entry - registry_presets = PLUGIN_PRESETS.get(resolved_name) - preset_data = registry_presets if registry_presets else plugin.preset_data + preset_data = _resolve_preset(resolved_name, plugin.role, plugin.preset_data) return _build_plugin_element(display_name, filename, uid_guid, preset_data) - # Built-in VST2 plugins (ReaEQ, ReaComp, etc.) — .dll format - dll_map = { - "ReaEQ": "reaeq.dll", - "ReaComp": "reacomp.dll", - "ReaVerbate": "reaverbate.dll", - "ReaDelay": "readelay.dll", - "ReaCast": "reacast.dll", - "ReaFIR": "reafir.dll", - "ReaGate": "reagate.dll", - "ReaLimit": "realimit.dll", - "ReaPitch": "reapitch.dll", - "ReaVerb": "reaverb.dll", - "ReaXcomp": "reaxcomp.dll", - } + # Fallback built-in VST2 path (no params, no registry entry) dll_name = dll_map.get(plugin.name, plugin.path) param_slots = ["0"] * 19 return Element("VST", [plugin.name, dll_name, "0", "", *param_slots]) @@ -1803,27 +1864,58 @@ class RPPBuilder: source = Element("SOURCE", ["WAVE"]) source.append(["FILE", clip.audio_path]) item.append(source) + if clip.vol_mult != 1.0: + item.append(["D_VOL", str(clip.vol_mult)]) elif clip.is_midi: item.append(self._build_midi_source(clip)) return item def _build_midi_source(self, clip: ClipDef) -> Element: - """Build a SOURCE MIDI Element with E-lines.""" + """Build a SOURCE MIDI Element with E-lines, including CC events.""" source = Element("SOURCE", ["MIDI"]) source.append(["HASDATA", "1", "960", "QN"]) ppq = 960 - sorted_notes = sorted(clip.midi_notes, key=lambda n: n.start) + + # Merge notes and CC events into a single time-sorted sequence. + # Each entry: (time_beats, "note", MidiNote) or (time_beats, "cc", CCEvent) + events: list[tuple[float, str, object]] = [] + for note in clip.midi_notes: + events.append((note.start, "note", note)) + for cc in clip.midi_cc: + events.append((cc.time, "cc", cc)) + events.sort(key=lambda x: x[0]) + + # Post-processing fallback: scale velocity by vol_mult + vol = clip.vol_mult cursor = 0.0 - for note in sorted_notes: - start_ticks = int(note.start * ppq) - delta = start_ticks - cursor - cursor = start_ticks + for evt_time, evt_kind, evt_obj in events: + if evt_kind == "note": + note = evt_obj + note: object # type hint for IDE — real type is MidiNote + start_ticks = int(note.start * ppq) + delta = start_ticks - cursor + cursor = start_ticks - source.append(['E', str(delta), '90', f'{note.pitch:02x}', f'{note.velocity:02x}']) - off_delta = int(note.duration * ppq) - source.append(['E', str(off_delta), '80', f'{note.pitch:02x}', '00']) + velocity = int(note.velocity * vol) if vol != 1.0 else note.velocity + velocity = max(1, min(127, velocity)) + + source.append(['E', str(delta), '90', f'{note.pitch:02x}', f'{velocity:02x}']) + off_delta = int(note.duration * ppq) + source.append(['E', str(off_delta), '80', f'{note.pitch:02x}', '00']) + else: # "cc" + cc = evt_obj + cc: object + cc_ticks = int(cc.time * ppq) + delta = cc_ticks - cursor + cursor = cc_ticks # CC events contribute zero ticks to cursor + + source.append([ + 'E', str(delta), 'B0', + f'{cc.controller:02x}', + f'{cc.value:02x}', + ]) return source \ No newline at end of file diff --git a/src/reaper_builder/preset_transformer.py b/src/reaper_builder/preset_transformer.py new file mode 100644 index 0000000..5f839fb --- /dev/null +++ b/src/reaper_builder/preset_transformer.py @@ -0,0 +1,102 @@ +"""Role-aware preset transformation. + +PresetTransformer decodes base64 preset data, modifies role-specific +parameters, and re-encodes. Each supported plugin has its own decoder. + +For MVP: the transformer is a stub — role variants currently share +the same default preset data. Full parameter modification will be +implemented in a future iteration. +""" + +from __future__ import annotations + +import base64 +import json +import re + + +class PresetTransformer: + """Transforms plugin preset data for a specific track role. + + Each supported plugin has a decoder that base64-decodes preset chunks, + a modifier that tweaks role-specific parameters, and re-encodes. + + For MVP: all transforms return the default chunks unchanged. + """ + + @staticmethod + def derive(plugin: str, default_chunks: list[str], role: str) -> list[str]: + """Derive role-specific preset data from default chunks. + + Args: + plugin: PLUGIN_REGISTRY key (e.g. "Serum_2", "Decapitator") + default_chunks: Base64 preset chunks for the \"default\" role. + role: Track role string (e.g. "bass", "lead", "drums"). + + Returns: + Modified preset chunks for the given role. For MVP, returns + default_chunks unchanged since full parameter modification + is not yet implemented. + """ + transformer = _TRANSFORMERS.get(plugin) + if transformer is not None: + return transformer(default_chunks, role) + return list(default_chunks) + + +# --------------------------------------------------------------------------- +# Per-plugin transformers (MVP stubs) +# --------------------------------------------------------------------------- + + +def _transform_serum(chunks: list[str], role: str) -> list[str]: + """Transform Serum_2 preset for a specific role. + + Serum preset format: base64 → JSON. The processor component has: + - processor.osc.type (0=sine, 1=saw, 2=square, 3=triangle) + - processor.filter.cutoff (Hz, float) + - processor.fx (bypass bits) + + For MVP: returns default chunks unchanged. + Future: bass→sine+low_cutoff, lead→saw+open_filter. + """ + return list(chunks) + + +def _transform_decapitator(chunks: list[str], role: str) -> list[str]: + """Transform Decapitator preset for a specific role. + + Decapitator format: base64 → key=value text. Modifiable params: + - Drive (float 0-1) + - Tone (float 0-1) + - Style (A-E letter) + + For MVP: returns default chunks unchanged. + Future: drums→high_drive+A_style, bass→low_drive+E_style. + """ + return list(chunks) + + +def _transform_omnisphere(chunks: list[str], role: str) -> list[str]: + """Transform Omnisphere preset for a specific role. + + Omnisphere format: base64 → SynthMaster text block. Modifiable params: + - Atk (attack time) + - Dec (decay time) + - Filter Freq + + For MVP: returns default chunks unchanged. + Future: pad→slow_attack+filter_mod, chords→faster_attack. + """ + return list(chunks) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_TRANSFORMERS: dict[str, object] = { + "Serum_2": _transform_serum, + "Decapitator": _transform_decapitator, + "Omnisphere": _transform_omnisphere, +} diff --git a/tests/test_calibrator.py b/tests/test_calibrator.py new file mode 100644 index 0000000..aabeb83 --- /dev/null +++ b/tests/test_calibrator.py @@ -0,0 +1,631 @@ +"""Unit tests for the calibrator module — role-based mix calibration.""" +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Phase 1.1: Presets data structure +# --------------------------------------------------------------------------- + +class TestPresets: + """Verify that all preset dictionaries exist with correct structure.""" + + def test_volume_presets_has_all_roles(self): + """VOLUME_PRESETS must contain all 7 roles with float values.""" + from src.calibrator.presets import VOLUME_PRESETS + + assert isinstance(VOLUME_PRESETS, dict) + expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"} + assert set(VOLUME_PRESETS.keys()) == expected_roles + for role, vol in VOLUME_PRESETS.items(): + assert isinstance(vol, float), f"Volume for {role} should be float" + assert 0.0 <= vol <= 1.0, f"Volume for {role} must be 0-1, got {vol}" + + def test_volume_presets_correct_values(self): + """VOLUME_PRESETS values must match the calibrated targets.""" + from src.calibrator.presets import VOLUME_PRESETS + + assert VOLUME_PRESETS["drumloop"] == 0.85 + assert VOLUME_PRESETS["bass"] == 0.82 + assert VOLUME_PRESETS["chords"] == 0.75 + assert VOLUME_PRESETS["lead"] == 0.80 + assert VOLUME_PRESETS["clap"] == 0.78 + assert VOLUME_PRESETS["pad"] == 0.70 + assert VOLUME_PRESETS["perc"] == 0.80 + + def test_eq_presets_has_all_roles(self): + """EQ_PRESETS must contain the 7 roles with ReaEQ param dicts.""" + from src.calibrator.presets import EQ_PRESETS + + expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"} + assert set(EQ_PRESETS.keys()) == expected_roles + for role, params in EQ_PRESETS.items(): + assert isinstance(params, dict), f"EQ for {role} should be dict" + assert 0 in params, f"EQ for {role} must have slot 0 (enabled)" + assert 1 in params, f"EQ for {role} must have slot 1 (filter type)" + assert 2 in params, f"EQ for {role} must have slot 2 (frequency)" + + def test_eq_presets_hpf_lpf(self): + """EQ_PRESETS must have correct HPF/LPF assignments.""" + from src.calibrator.presets import EQ_PRESETS + + # HPF at 60Hz for drums + assert EQ_PRESETS["drumloop"][1] == 1 # HPF type + assert EQ_PRESETS["drumloop"][2] == 60.0 + + # LPF at 300Hz for bass + assert EQ_PRESETS["bass"][1] == 0 # LPF type + assert EQ_PRESETS["bass"][2] == 300.0 + + # HPF at 200Hz for chords, lead, clap + for role in ("chords", "lead", "clap", "perc"): + assert EQ_PRESETS[role][1] == 1, f"{role} should be HPF" + assert EQ_PRESETS[role][2] == 200.0, f"{role} HPF should be 200Hz" + + # HPF at 100Hz for pad + assert EQ_PRESETS["pad"][1] == 1 # HPF type + assert EQ_PRESETS["pad"][2] == 100.0 + + def test_pan_presets_has_all_roles(self): + """PAN_PRESETS must contain all 7 roles with float pan values.""" + from src.calibrator.presets import PAN_PRESETS + + expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"} + assert set(PAN_PRESETS.keys()) == expected_roles + for role, pan in PAN_PRESETS.items(): + assert isinstance(pan, float), f"Pan for {role} should be float" + assert -1.0 <= pan <= 1.0, f"Pan for {role} must be -1 to 1, got {pan}" + + def test_pan_presets_correct_values(self): + """PAN_PRESETS must match spec values.""" + from src.calibrator.presets import PAN_PRESETS + + assert PAN_PRESETS["drumloop"] == 0.0 + assert PAN_PRESETS["bass"] == 0.0 + assert PAN_PRESETS["chords"] == 0.5 + assert PAN_PRESETS["lead"] == 0.3 + assert PAN_PRESETS["clap"] == -0.15 + assert PAN_PRESETS["pad"] == -0.5 + assert PAN_PRESETS["perc"] == 0.12 + + def test_send_presets_has_all_roles(self): + """SEND_PRESETS must contain all 7 roles with (reverb, delay) tuples.""" + from src.calibrator.presets import SEND_PRESETS + + expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"} + assert set(SEND_PRESETS.keys()) == expected_roles + for role, sends in SEND_PRESETS.items(): + assert isinstance(sends, tuple), f"Sends for {role} should be tuple" + assert len(sends) == 2, f"Sends for {role} should be (reverb, delay)" + assert all(0.0 <= s <= 1.0 for s in sends), f"Sends out of range for {role}" + + def test_send_presets_correct_values(self): + """SEND_PRESETS must match task values with spec fallback.""" + from src.calibrator.presets import SEND_PRESETS + + assert SEND_PRESETS["drumloop"] == (0.10, 0.00) + assert SEND_PRESETS["bass"] == (0.05, 0.00) # task override: delay 0.0 + assert SEND_PRESETS["chords"] == (0.40, 0.10) # task value + assert SEND_PRESETS["lead"] == (0.30, 0.15) # task override: reverb 0.30 + assert SEND_PRESETS["clap"] == (0.10, 0.00) + assert SEND_PRESETS["pad"] == (0.50, 0.20) # task value + assert SEND_PRESETS["perc"] == (0.10, 0.00) + + +# --------------------------------------------------------------------------- +# Phase 1.2: SongMeta.calibrate field +# --------------------------------------------------------------------------- + +class TestSongMetaCalibrate: + """Verify SongMeta has calibrate: bool = True field.""" + + def test_song_meta_default_calibrate_true(self): + """SongMeta should default calibrate to True.""" + from src.core.schema import SongMeta + + meta = SongMeta(bpm=95, key="Am") + assert meta.calibrate is True + + def test_song_meta_calibrate_false(self): + """SongMeta should accept calibrate=False.""" + from src.core.schema import SongMeta + + meta = SongMeta(bpm=95, key="Am", calibrate=False) + assert meta.calibrate is False + + def test_song_meta_serialization_includes_calibrate(self): + """to_json should include the calibrate field.""" + from src.core.schema import SongMeta + + meta = SongMeta(bpm=95, key="Am") + json_str = meta.__dict__ + assert "calibrate" in json_str + assert json_str["calibrate"] is True + + +# --------------------------------------------------------------------------- +# Phase 1.3: Calibrator._resolve_role() +# --------------------------------------------------------------------------- + +class TestResolveRole: + """Verify track name → role key mapping.""" + + @staticmethod + def _cal(): + from src.calibrator import Calibrator + return Calibrator + + def test_drumloop_roles(self): + """Drumloop track names should resolve to drumloop role.""" + C = self._cal() + assert C._resolve_role("Drumloop") == "drumloop" + assert C._resolve_role("drumloop") == "drumloop" + + def test_bass_roles(self): + """Bass track names should resolve to bass role.""" + C = self._cal() + assert C._resolve_role("808 Bass") == "bass" + assert C._resolve_role("808 bass") == "bass" + assert C._resolve_role("bass") == "bass" + + def test_chords_role(self): + C = self._cal() + assert C._resolve_role("Chords") == "chords" + assert C._resolve_role("chords") == "chords" + + def test_lead_role(self): + C = self._cal() + assert C._resolve_role("Lead") == "lead" + assert C._resolve_role("lead") == "lead" + + def test_clap_role(self): + C = self._cal() + assert C._resolve_role("Clap") == "clap" + assert C._resolve_role("clap") == "clap" + + def test_pad_role(self): + C = self._cal() + assert C._resolve_role("Pad") == "pad" + assert C._resolve_role("pad") == "pad" + + def test_perc_role(self): + C = self._cal() + assert C._resolve_role("Perc") == "perc" + assert C._resolve_role("perc") == "perc" + + def test_return_tracks_return_none(self): + """Return tracks (Reverb, Delay) should return None.""" + C = self._cal() + assert C._resolve_role("Reverb") is None + assert C._resolve_role("Delay") is None + + def test_unknown_track_returns_none(self): + """Unknown track names should return None.""" + C = self._cal() + assert C._resolve_role("Unknown") is None + assert C._resolve_role("Vocals") is None + assert C._resolve_role("") is None + + +# --------------------------------------------------------------------------- +# Phase 2: Core Calibrator methods +# --------------------------------------------------------------------------- + +import pytest as _pytest +from src.core.schema import ( + SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef, +) + + +def _make_fixture_song() -> SongDefinition: + """Create a SongDefinition with representative tracks.""" + meta = SongMeta(bpm=95, key="Am") + tracks = [ + TrackDef(name="Drumloop", volume=0.85, plugins=[]), + TrackDef(name="Perc", volume=0.78, plugins=[]), + TrackDef(name="808 Bass", volume=0.72, plugins=[]), + TrackDef(name="Chords", volume=0.70, plugins=[]), + TrackDef(name="Lead", volume=0.75, plugins=[]), + TrackDef(name="Clap", volume=0.80, plugins=[]), + TrackDef(name="Pad", volume=0.65, plugins=[]), + TrackDef(name="Reverb", volume=0.80, plugins=[]), + TrackDef(name="Delay", volume=0.80, plugins=[]), + ] + return SongDefinition( + meta=meta, + tracks=tracks, + master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"], + ) + + +class TestCalibrateVolumes: + def test_volumes_calibrated_to_presets(self): + """_calibrate_volumes should set volume from VOLUME_PRESETS by role.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._calibrate_volumes(song) + + volumes = {t.name: t.volume for t in song.tracks} + assert volumes["Drumloop"] == 0.85 + assert volumes["808 Bass"] == 0.82 + assert volumes["Chords"] == 0.75 + assert volumes["Lead"] == 0.80 + assert volumes["Clap"] == 0.78 + assert volumes["Pad"] == 0.70 + assert volumes["Perc"] == 0.80 + + def test_return_tracks_volume_unchanged(self): + """Return tracks should not have their volume modified.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._calibrate_volumes(song) + + rev = [t for t in song.tracks if t.name == "Reverb"][0] + dly = [t for t in song.tracks if t.name == "Delay"][0] + assert rev.volume == 0.80 # unchanged + assert dly.volume == 0.80 # unchanged + + def test_unknown_role_volume_unchanged(self): + """Tracks with unknown role should keep their original volume.""" + from src.calibrator import Calibrator + + meta = SongMeta(bpm=95, key="Am") + song = SongDefinition( + meta=meta, + tracks=[TrackDef(name="UnknownThing", volume=0.42)], + ) + Calibrator._calibrate_volumes(song) + assert song.tracks[0].volume == 0.42 # unchanged + + +class TestCalibratePans: + def test_pans_calibrated_to_presets(self): + """_calibrate_pans should set pan from PAN_PRESETS.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._calibrate_pans(song) + + pans = {t.name: t.pan for t in song.tracks} + assert pans["Drumloop"] == 0.0 + assert pans["808 Bass"] == 0.0 + assert pans["Chords"] == 0.5 + assert pans["Lead"] == 0.3 + assert pans["Clap"] == -0.15 + assert pans["Pad"] == -0.5 + assert pans["Perc"] == 0.12 + + def test_return_tracks_pan_unchanged(self): + """Return tracks should keep their original pan.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + # Give return tracks non-zero pans to verify they're preserved + for t in song.tracks: + if t.name in ("Reverb", "Delay"): + t.pan = 0.5 + Calibrator._calibrate_pans(song) + + rev = [t for t in song.tracks if t.name == "Reverb"][0] + dly = [t for t in song.tracks if t.name == "Delay"][0] + assert rev.pan == 0.5 + assert dly.pan == 0.5 + + def test_unknown_role_pan_unchanged(self): + """Unknown roles should keep original pan.""" + from src.calibrator import Calibrator + + meta = SongMeta(bpm=95, key="Am") + song = SongDefinition( + meta=meta, + tracks=[TrackDef(name="Vocals", pan=0.75)], + ) + Calibrator._calibrate_pans(song) + assert song.tracks[0].pan == 0.75 + + +class TestCalibrateSends: + def test_sends_calibrated_to_presets(self): + """_calibrate_sends should set send_level dict from SEND_PRESETS.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + # 7 content tracks + 2 return tracks = 9 total + # Reverb is at index 7, Delay at index 8 + Calibrator._calibrate_sends(song) + + drum = [t for t in song.tracks if t.name == "Drumloop"][0] + assert drum.send_level.get(7) == 0.10 + assert drum.send_level.get(8) == 0.00 + + bass = [t for t in song.tracks if t.name == "808 Bass"][0] + assert bass.send_level.get(7) == 0.05 + assert bass.send_level.get(8) == 0.00 + + chords = [t for t in song.tracks if t.name == "Chords"][0] + assert chords.send_level.get(7) == 0.40 + assert chords.send_level.get(8) == 0.10 + + lead = [t for t in song.tracks if t.name == "Lead"][0] + assert lead.send_level.get(7) == 0.30 + assert lead.send_level.get(8) == 0.15 + + clap = [t for t in song.tracks if t.name == "Clap"][0] + assert clap.send_level.get(7) == 0.10 + assert clap.send_level.get(8) == 0.00 + + pad = [t for t in song.tracks if t.name == "Pad"][0] + assert pad.send_level.get(7) == 0.50 + assert pad.send_level.get(8) == 0.20 + + perc = [t for t in song.tracks if t.name == "Perc"][0] + assert perc.send_level.get(7) == 0.10 + assert perc.send_level.get(8) == 0.00 + + def test_return_tracks_sends_unchanged(self): + """Return tracks should not have sends set.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._calibrate_sends(song) + + rev = [t for t in song.tracks if t.name == "Reverb"][0] + dly = [t for t in song.tracks if t.name == "Delay"][0] + assert rev.send_level == {} + assert dly.send_level == {} + + +class TestCalibrateEq: + def test_reaeq_plugin_prepended(self): + """_calibrate_eq should prepend ReaEQ with HPF/LPF params to non-return tracks.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._calibrate_eq(song) + + # Drumloop: HPF 60Hz + drum = [t for t in song.tracks if t.name == "Drumloop"][0] + assert len(drum.plugins) >= 1 + eq = drum.plugins[0] + assert eq.name == "ReaEQ" + assert eq.params[0] == 1 + assert eq.params[1] == 1 # HPF + assert eq.params[2] == 60.0 + + # Bass: LPF 300Hz + bass = [t for t in song.tracks if t.name == "808 Bass"][0] + eq = bass.plugins[0] + assert eq.name == "ReaEQ" + assert eq.params[1] == 0 # LPF + assert eq.params[2] == 300.0 + + # Chords: HPF 200Hz + chords = [t for t in song.tracks if t.name == "Chords"][0] + eq = chords.plugins[0] + assert eq.params[1] == 1 # HPF + assert eq.params[2] == 200.0 + + # Pad: HPF 100Hz + pad = [t for t in song.tracks if t.name == "Pad"][0] + eq = pad.plugins[0] + assert eq.params[1] == 1 # HPF + assert eq.params[2] == 100.0 + + def test_return_tracks_no_reaeq(self): + """Return tracks should not get ReaEQ plugins.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._calibrate_eq(song) + + rev = [t for t in song.tracks if t.name == "Reverb"][0] + dly = [t for t in song.tracks if t.name == "Delay"][0] + assert rev.plugins == [] + assert dly.plugins == [] + + def test_reaeq_index_zero(self): + """ReaEQ must be at index 0 (prepended to existing plugins).""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + # Add an existing plugin to a track + lead = [t for t in song.tracks if t.name == "Lead"][0] + lead.plugins = [PluginDef(name="Serum 2", path="Serum2.vst3", index=0)] + + Calibrator._calibrate_eq(song) + + assert len(lead.plugins) == 2 + assert lead.plugins[0].name == "ReaEQ" + assert lead.plugins[0].index == 0 + assert lead.plugins[1].name == "Serum 2" + + def test_unknown_role_no_reaeq(self): + """Tracks with unknown role should not get ReaEQ.""" + from src.calibrator import Calibrator + + meta = SongMeta(bpm=95, key="Am") + song = SongDefinition( + meta=meta, + tracks=[TrackDef(name="Vocals", plugins=[])], + ) + Calibrator._calibrate_eq(song) + assert song.tracks[0].plugins == [] + + +class TestSwapMasterChain: + def test_ozone12_master_chain(self): + """_swap_master_chain should replace master_plugins with Ozone 12 triplet.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator._swap_master_chain(song) + assert song.master_plugins == [ + "Ozone_12_Equalizer", + "Ozone_12_Dynamics", + "Ozone_12_Maximizer", + ] + + def test_fallback_to_fabfilter(self): + """When Ozone is not in PLUGIN_REGISTRY, fall back to FabFilter trio.""" + from src.calibrator import Calibrator + from src.reaper_builder import PLUGIN_REGISTRY + + # Make a copy of the registry without Ozone entries + original = dict(PLUGIN_REGISTRY) + try: + for k in list(PLUGIN_REGISTRY.keys()): + if k.startswith("Ozone_12_"): + del PLUGIN_REGISTRY[k] # type: ignore + + song = _make_fixture_song() + Calibrator._swap_master_chain(song) + assert song.master_plugins == ["Pro-Q_3", "Pro-C_2", "Pro-L_2"] + finally: + # Restore + PLUGIN_REGISTRY.clear() + PLUGIN_REGISTRY.update(original) + + +class TestCalibratorApply: + def test_apply_orchestrates_all_calibrations(self): + """Calibrator.apply() should run all _calibrate_* and _swap_master_chain.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + result = Calibrator.apply(song) + + # Same object returned (in-place mutation) + assert result is song + + # Volumes applied + drum = [t for t in song.tracks if t.name == "Drumloop"][0] + assert drum.volume == 0.85 + + # Pans applied + assert drum.pan == 0.0 + + # Sends applied + assert drum.send_level.get(7) == 0.10 + + # EQ applied (ReaEQ present) + assert drum.plugins[0].name == "ReaEQ" + + # Master chain upgraded + assert song.master_plugins == [ + "Ozone_12_Equalizer", + "Ozone_12_Dynamics", + "Ozone_12_Maximizer", + ] + + def test_apply_skips_bass_lpf_eq(self): + """Bass track should get LPF, not HPF.""" + from src.calibrator import Calibrator + + song = _make_fixture_song() + Calibrator.apply(song) + + bass = [t for t in song.tracks if t.name == "808 Bass"][0] + eq = bass.plugins[0] + assert eq.params[1] == 0 # LPF type + assert eq.params[2] == 300.0 + + +# --------------------------------------------------------------------------- +# Phase 3: Builder integration + compose wiring +# --------------------------------------------------------------------------- + +class TestBuildPluginWithParams: + """Verify _build_plugin populates VST2 param slots from PluginDef.params.""" + + def test_build_plugin_with_params_sets_param_slots(self): + """PluginDef with params for built-in VST2 should populate param slots.""" + from src.reaper_builder import RPPBuilder + from src.core.schema import SongDefinition, SongMeta, TrackDef, PluginDef + + plugin = PluginDef( + name="ReaEQ", + path="reaeq.dll", + index=0, + params={0: 1, 1: 1, 2: 200.0}, + ) + meta = SongMeta(bpm=95, key="Am") + song = SongDefinition(meta=meta, tracks=[TrackDef(name="Test", plugins=[plugin])]) + builder = RPPBuilder(song, seed=0) + elem = builder._build_plugin(plugin) + + # When params is non-empty for a built-in VST2, use built-in path + # Format: [display_name, filename, "0", "", *param_slots] + attrs = elem.attrib + assert attrs[0] == "ReaEQ" + assert attrs[2] == "0" + # attrs[4:] are the 19 param slots + param_slots = attrs[4:] + assert len(param_slots) == 19 + assert param_slots[0] == "1" # band enabled + assert param_slots[1] == "1" # filter type (HPF) + assert param_slots[2] == "200.0" # frequency + + def test_build_plugin_without_params_uses_zeros(self): + """PluginDef without params for built-in VST2 should use default zeros.""" + from src.reaper_builder import RPPBuilder + from src.core.schema import SongDefinition, SongMeta, TrackDef, PluginDef + + plugin = PluginDef( + name="FakeBuiltin", + path="fakebuiltin.dll", + index=0, + params={}, + ) + meta = SongMeta(bpm=95, key="Am") + song = SongDefinition(meta=meta, tracks=[TrackDef(name="Test", plugins=[plugin])]) + builder = RPPBuilder(song, seed=0) + elem = builder._build_plugin(plugin) + + param_slots = elem.attrib[4:] + assert all(s == "0" for s in param_slots) + + +class TestNoCalibrateFlag: + """Verify --no-calibrate CLI flag behavior.""" + + def test_no_calibrate_preserves_original_master(self): + """When calibrator is skipped, master_plugins should stay unchanged.""" + from src.core.schema import SongDefinition, SongMeta + + meta = SongMeta(bpm=95, key="Am", calibrate=False) + song = SongDefinition( + meta=meta, + tracks=[], + master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"], + ) + + if meta.calibrate: + from src.calibrator import Calibrator + Calibrator.apply(song) + + assert song.master_plugins == ["Pro-Q_3", "Pro-C_2", "Pro-L_2"] + + def test_calibrate_flag_is_true_default(self): + """SongMeta.calibrate should default to True (calibration enabled).""" + from src.core.schema import SongMeta + meta = SongMeta(bpm=95, key="Am") + assert meta.calibrate is True + + def test_no_calibrate_skips_volume_changes(self): + """When calibrate=False, volumes should not be touched.""" + from src.core.schema import SongDefinition, SongMeta, TrackDef + + meta = SongMeta(bpm=95, key="Am", calibrate=False) + song = SongDefinition( + meta=meta, + tracks=[TrackDef(name="Drumloop", volume=0.5)], + ) + + if meta.calibrate: + from src.calibrator import Calibrator + Calibrator.apply(song) + + assert song.tracks[0].volume == 0.5 # unchanged diff --git a/tests/test_chords.py b/tests/test_chords.py new file mode 100644 index 0000000..d9b574d --- /dev/null +++ b/tests/test_chords.py @@ -0,0 +1,312 @@ +"""Unit tests for ChordEngine — determinism, voice leading, inversions, emotions. + +Strict TDD: RED → GREEN → TRIANGULATE → REFACTOR for each spec requirement. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1])) + +import pytest +from src.composer.chords import ChordEngine, EMOTION_PROGRESSIONS + + +# --------------------------------------------------------------------------- +# R1: Determinism — same seed → same output +# --------------------------------------------------------------------------- + +class TestDeterminism: + """R1: ChordEngine(key, seed) MUST produce identical progressions.""" + + def test_same_seed_same_output(self): + """GIVEN ChordEngine("Am", seed=42) + WHEN progression(8) called twice + THEN identical output.""" + engine = ChordEngine("Am", seed=42) + r1 = engine.progression(8) + r2 = engine.progression(8) + assert r1 == r2, "Same seed must produce identical progressions" + assert len(r1) == 8, "8 bars @ 4 bpc = 8 chords" + + def test_different_seed_different_output(self): + """Different seeds SHOULD produce different voicing choices.""" + e1 = ChordEngine("Am", seed=42) + e2 = ChordEngine("Am", seed=99) + r1 = e1.progression(8) + r2 = e2.progression(8) + assert r1 != r2, ( + "Different seeds should produce different voicings " + "(rng used as voice-leading tiebreaker)" + ) + + def test_same_seed_different_keys_differ(self): + """Same seed with different keys should differ.""" + e_am = ChordEngine("Am", seed=42) + e_dm = ChordEngine("Dm", seed=42) + r1 = e_am.progression(8) + r2 = e_dm.progression(8) + assert r1 != r2, "Different tonic should produce different pitch sets" + + +# --------------------------------------------------------------------------- +# R2: Voice leading ≤ 4 semitones per voice +# --------------------------------------------------------------------------- + +class TestVoiceLeadingBounds: + """R2: Voice leading MUST cap at 4 semitones per voice.""" + + def test_all_adjacent_pairs_within_4_semitones(self): + """GIVEN any 2 consecutive chords from a progression + WHEN computing voice leading + THEN no voice moves more than 4 semitones.""" + engine = ChordEngine("Am", seed=42) + voicings = engine.progression(8, emotion="romantic") + assert len(voicings) >= 2, "Need at least 2 chords to test voice leading" + + for i in range(len(voicings) - 1): + a = voicings[i] + b = voicings[i + 1] + assert len(a) == len(b), ( + f"Chords {i} and {i+1} have different voice counts: " + f"{len(a)} vs {len(b)}" + ) + for j, (pa, pb) in enumerate(zip(a, b)): + leap = abs(pb - pa) + assert leap <= 4, ( + f"Voice {j} leaped {leap} semitones " + f"({pa}→{pb}) between chord {i} and {i+1}" + ) + + def test_voice_leading_on_dark_progression(self): + """Voice leading bounds hold for dark emotion too.""" + engine = ChordEngine("Am", seed=42) + voicings = engine.progression(8, emotion="dark") + for i in range(len(voicings) - 1): + for pa, pb in zip(voicings[i], voicings[i + 1]): + assert abs(pb - pa) <= 4 + + def test_voice_leading_on_club_progression(self): + """Voice leading bounds hold for club emotion.""" + engine = ChordEngine("Am", seed=42) + voicings = engine.progression(8, emotion="club") + for i in range(len(voicings) - 1): + for pa, pb in zip(voicings[i], voicings[i + 1]): + assert abs(pb - pa) <= 4 + + def test_voice_leading_on_classic_progression(self): + """Voice leading bounds hold for classic emotion.""" + engine = ChordEngine("Am", seed=42) + voicings = engine.progression(8, emotion="classic") + for i in range(len(voicings) - 1): + for pa, pb in zip(voicings[i], voicings[i + 1]): + assert abs(pb - pa) <= 4 + + +# --------------------------------------------------------------------------- +# R3: Inversions +# --------------------------------------------------------------------------- + +class TestInversions: + """R3: SHALL support 3 inversion modes.""" + + def test_root_inversion_no_change(self): + """Root inversion = identity (notes unchanged).""" + engine = ChordEngine("Am", seed=0) + voicing = [57, 60, 64] # Am root: A3, C4, E4 + result = engine._apply_inversion(voicing, "root") + assert sorted(result) == sorted(voicing), "Root inversion should not reorder notes" + + def test_first_inversion_bass_is_third(self): + """First inversion: third becomes lowest note.""" + engine = ChordEngine("Am", seed=0) + voicing = [57, 60, 64] # Am root: A=root, C=third, E=fifth + result = engine._apply_inversion(voicing, "first") + assert min(result) == 60, ( + f"First inversion bass should be third (60 = C4), got {min(result)}" + ) + + def test_second_inversion_bass_is_fifth(self): + """Second inversion: fifth becomes lowest note.""" + engine = ChordEngine("Am", seed=0) + voicing = [57, 60, 64] + result = engine._apply_inversion(voicing, "second") + assert min(result) == 64, ( + f"Second inversion bass should be fifth (64 = E4), got {min(result)}" + ) + + def test_first_inversion_on_major_chord(self): + """First inversion works on major chords too.""" + engine = ChordEngine("C", seed=0) + # C major: C=60, E=64, G=67 + voicing = [60, 64, 67] + result = engine._apply_inversion(voicing, "first") + assert min(result) == 64, "First inversion of Cmaj: bass = E4 (64)" + + +# --------------------------------------------------------------------------- +# R4: Emotion divergence +# --------------------------------------------------------------------------- + +class TestEmotionDivergence: + """R4: MUST support 4 emotion modes with distinct progressions.""" + + def test_all_four_distinct(self): + """GIVEN ChordEngine("Am", seed=0) with 4 emotions + WHEN progression(8) called per emotion + THEN all 4 output sequences differ.""" + emotions = ["romantic", "dark", "club", "classic"] + results = {} + for emo in emotions: + engine = ChordEngine("Am", seed=0) + results[emo] = engine.progression(8, emotion=emo) + + seen = set() + for emo, prog in results.items(): + encoded = str(prog) + assert encoded not in seen, ( + f"Emotion '{emo}' produced duplicate progression" + ) + seen.add(encoded) + + def test_invalid_emotion_falls_back_to_classic(self): + """GIVEN unknown emotion 'angry' + WHEN progression(8) called + THEN defaults to classic progression, no error.""" + engine = ChordEngine("Am", seed=0) + classic = engine.progression(8, emotion="classic") + angry = engine.progression(8, emotion="angry") + assert angry == classic, ( + "Unknown emotion should fall back to classic progression" + ) + + def test_each_emotion_has_four_degrees(self): + """Each emotion progression must have exactly 4 chord types.""" + for emo, degrees in EMOTION_PROGRESSIONS.items(): + assert len(degrees) == 4, ( + f"Emotion '{emo}' has {len(degrees)} degrees, expected 4" + ) + for degree_offset, quality in degrees: + assert isinstance(degree_offset, int), ( + f"Degree offset must be int, got {type(degree_offset)}" + ) + assert quality in ("maj", "min", "dim", "aug", "7", "m7"), ( + f"Unknown quality '{quality}'" + ) + + +# --------------------------------------------------------------------------- +# R5: Empty / zero bars +# --------------------------------------------------------------------------- + +class TestEdgeCases: + """Edge cases from spec requirements.""" + + def test_zero_bars_returns_empty(self): + """0 bars → empty list (R5).""" + engine = ChordEngine("Am", seed=42) + result = engine.progression(0) + assert result == [], "0 bars should return empty list" + + def test_single_bar(self): + """1 bar @ 4 bpc = 1 chord.""" + engine = ChordEngine("Am", seed=42) + result = engine.progression(1) + assert len(result) == 1, f"1 bar = 1 chord, got {len(result)}" + + def test_partial_bar(self): + """3 bars = 3 chords @ 4 bpc.""" + engine = ChordEngine("Am", seed=42) + result = engine.progression(3) + assert len(result) == 3, f"3 bars = 3 chords @ 4 bpc" + + def test_each_chord_is_three_note_triad(self): + """All chords should be 3-note triads (min/maj quality).""" + engine = ChordEngine("Am", seed=42) + for emotion in ("romantic", "dark", "club", "classic"): + voicings = engine.progression(8, emotion=emotion) + for i, voicing in enumerate(voicings): + assert len(voicing) == 3, ( + f"{emotion} chord {i}: expected 3 notes, got {len(voicing)}" + ) + + +# --------------------------------------------------------------------------- +# R7: Integration with compose.py +# --------------------------------------------------------------------------- + +class TestComposeIntegration: + """R7: build_chords_track() SHALL delegate to ChordEngine.""" + + def test_build_chords_uses_chordengine(self): + """Verify build_chords_track calls ChordEngine.progression.""" + from unittest.mock import patch + from scripts.compose import build_chords_track + from src.core.schema import SectionDef + + sections = [SectionDef(name="verse", bars=4, energy=1.0)] + offsets = [0.0] + + with patch("scripts.compose.ChordEngine") as MockEngine: + mock_engine = MockEngine.return_value + mock_engine.progression.return_value = [[57, 60, 64], [60, 64, 67]] + + track = build_chords_track( + sections, offsets, "A", True, + emotion="dark", inversion="first", + ) + + # Verify ChordEngine was instantiated + MockEngine.assert_called_once() + # Verify progression() was called with correct args + mock_engine.progression.assert_called_once_with( + 4, emotion="dark", beats_per_chord=4, inversion="first", + ) + assert track.name == "Chords" + assert len(track.clips) == 1 + + def test_compose_cli_emotion_flag_accepted(self, tmp_path): + """CLI --emotion dark generates output (R7 integration).""" + from unittest.mock import patch, MagicMock + + output = tmp_path / "test.rpp" + + with patch("scripts.compose.SampleSelector") as mock_cls: + mock_sel = MagicMock() + mock_sel._samples = [ + { + "role": "snare", + "perceptual": {"tempo": 0}, + "musical": {"key": "X"}, + "character": "sharp", + "original_path": "fake_clap.wav", + "original_name": "fake_clap.wav", + "file_hash": "clap123", + }, + ] + mock_sel.select.return_value = [ + MagicMock(sample={ + "original_path": "fake_clap.wav", + "file_hash": "clap123", + }), + ] + mock_sel.select_diverse.return_value = [] + mock_cls.return_value = mock_sel + + import importlib + import scripts.compose as _mod + + orig_argv = sys.argv + try: + sys.argv = [ + "compose", "--key", "Am", "--emotion", "dark", + "--inversion", "root", "--output", str(output), + ] + importlib.reload(_mod) + _mod.main() + finally: + sys.argv = orig_argv + + assert output.exists(), f"Expected {output} to exist" diff --git a/tests/test_compose_integration.py b/tests/test_compose_integration.py index 4c2d6a0..75b75e0 100644 --- a/tests/test_compose_integration.py +++ b/tests/test_compose_integration.py @@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock sys.path.insert(0, str(Path(__file__).parents[1])) import pytest -from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote +from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent from src.reaper_builder import RPPBuilder from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid @@ -152,7 +152,7 @@ class TestDrumloopFirstTracks: def test_all_tracks_created(self, tmp_path): output = _mock_main(tmp_path) content = output.read_text(encoding="utf-8") - for name in ("Drumloop", "Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"): + for name in ("Drumloop", "808 Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"): assert name in content, f"Expected track '{name}' in output" def test_clap_on_dembow_beats(self, tmp_path): @@ -197,24 +197,48 @@ class TestDrumloopFirstTracks: for s in starts: assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat" - def test_melody_uses_pentatonic(self): - from scripts.compose import build_melody_track + def test_melody_uses_hook_structure(self): + """Lead melody should use hook-based call-response from melody_engine.""" + from scripts.compose import build_melody_track, get_pentatonic from src.core.schema import SectionDef + from src.composer.melody_engine import _resolve_chord_tones sections = [SectionDef(name="chorus", bars=4, energy=1.0)] offsets = [0.0] track = build_melody_track(sections, offsets, "A", True, seed=42) assert len(track.clips) > 0, "Melody should have clips" - pitches = {n.pitch for n in track.clips[0].midi_notes} + + notes = track.clips[0].midi_notes + pitches = {n.pitch for n in notes} assert len(pitches) > 1, "Melody should use multiple notes" + # Verify chord-tone emphasis: quarter-position notes should favor chord tones + penta = get_pentatonic("A", True, 4) + get_pentatonic("A", True, 5) + chord_tones = _resolve_chord_tones("A", True, 0, 4) + + quarter_notes = [n for n in notes if abs(n.start % 1.0) < 0.001] + if quarter_notes: + chord_count = sum( + 1 for n in quarter_notes + if any(abs(n.pitch - ct) % 12 == 0 for ct in chord_tones) + ) + ratio = chord_count / len(quarter_notes) + assert ratio >= 0.5, ( + f"Hook chord tone ratio {ratio:.1%} below 50%" + ) + + # Verify notes span the section length + max_end = max(n.start + n.duration for n in notes) + assert max_end <= 16.0 + 0.001, "Notes should fit within 4 bars" + def test_master_chain_present(self, tmp_path): output = _mock_main(tmp_path) content = output.read_text(encoding="utf-8") - assert "Pro-Q" in content, "Expected Pro-Q 3 in master chain" - assert "Pro-C" in content, "Expected Pro-C 2 in master chain" - assert "Pro-L" in content, "Expected Pro-L 2 in master chain" + # After calibration, master chain uses Ozone 12 triplet (with spaces in RPP) + assert "Ozone 12" in content or "Pro-Q" in content, ( + "Expected Ozone 12 or Pro-Q in master chain" + ) def test_sends_wired(self, tmp_path): output = _mock_main(tmp_path) @@ -243,3 +267,192 @@ class TestBackwardCompat: assert tracks[1].name == "Delay" assert len(tracks[0].plugins) > 0 assert len(tracks[1].plugins) > 0 + + +class TestSectionEnergy: + """Integration tests for section energy curve — sparse vs dense sections.""" + + def test_section_rename_pre_chorus_not_build(self): + """SECTIONS uses 'pre-chorus' not 'build'.""" + from scripts.compose import SECTIONS + names = {name for name, _, _, _ in SECTIONS} + assert "pre-chorus" in names, "Expected 'pre-chorus' section" + assert "build" not in names, "'build' must be renamed to 'pre-chorus'" + + def test_intro_has_no_bass(self): + """Intro section should NOT have bass (sparse).""" + from scripts.compose import build_bass_track, build_section_structure + sections, offsets = build_section_structure() + intro_section = sections[0] + assert intro_section.name == "intro" + track = build_bass_track(sections, offsets, "A", True) + # Find clips whose position matches the intro offset + intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0] + assert len(intro_positions) == 0, "Intro should have no bass clips" + + def test_intro_has_no_chords(self): + """Intro section should NOT have chords (sparse).""" + from scripts.compose import build_chords_track, build_section_structure + sections, offsets = build_section_structure() + track = build_chords_track(sections, offsets, "A", True) + intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0] + assert len(intro_positions) == 0, "Intro should have no chord clips" + + def test_intro_has_no_lead(self): + """Intro section should NOT have lead (sparse).""" + from scripts.compose import build_lead_track, build_section_structure + sections, offsets = build_section_structure() + track = build_lead_track(sections, offsets, "A", True, seed=42) + intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0] + assert len(intro_positions) == 0, "Intro should have no lead clips" + + def test_chorus_has_bass_chords_lead(self): + """Chorus section should have bass, chords, and lead (full band).""" + from scripts.compose import ( + build_bass_track, build_chords_track, build_lead_track, + build_section_structure, + ) + sections, offsets = build_section_structure() + # Find the chorus section index (first "chorus") + chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus") + chorus_offset = offsets[chorus_idx] * 4.0 + + bass = build_bass_track(sections, offsets, "A", True) + chords = build_chords_track(sections, offsets, "A", True) + lead = build_lead_track(sections, offsets, "A", True, seed=42) + + assert any(c.position == chorus_offset for c in bass.clips), "Chorus should have bass" + assert any(c.position == chorus_offset for c in chords.clips), "Chorus should have chords" + assert any(c.position == chorus_offset for c in lead.clips), "Chorus should have lead" + + def test_chorus_clips_have_vol_mult(self): + """Clips in chorus sections should have vol_mult set from section.""" + from scripts.compose import build_drumloop_track, build_bass_track, build_section_structure + sections, offsets = build_section_structure() + chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus") + chorus_offset = offsets[chorus_idx] * 4.0 + + drumloop_track = build_drumloop_track(sections, offsets, seed=0) + bass_track = build_bass_track(sections, offsets, "A", True) + + # Audio clips get vol_mult + dl_clips = [c for c in drumloop_track.clips if c.position == chorus_offset] + if dl_clips: + assert dl_clips[0].vol_mult == 1.0, "Chorus drumloop vol_mult should be 1.0" + + # MIDI clips get vol_mult + bass_clips = [c for c in bass_track.clips if c.position == chorus_offset] + if bass_clips: + assert bass_clips[0].vol_mult == 1.0, "Chorus bass vol_mult should be 1.0" + + def test_velocity_scaled_in_intro_vs_chorus(self): + """Verse has lower velocity notes than chorus (velocity_mult 0.7 vs 1.0).""" + from scripts.compose import build_bass_track, build_section_structure + sections, offsets = build_section_structure() + verse_idx = next(i for i, s in enumerate(sections) if s.name == "verse") + verse_offset = offsets[verse_idx] * 4.0 + chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus") + chorus_offset = offsets[chorus_idx] * 4.0 + + track = build_bass_track(sections, offsets, "A", True) + verse_clip = next(c for c in track.clips if c.position == verse_offset) + chorus_clip = next(c for c in track.clips if c.position == chorus_offset) + + verse_vel = verse_clip.midi_notes[0].velocity if verse_clip.midi_notes else 0 + chorus_vel = chorus_clip.midi_notes[0].velocity if chorus_clip.midi_notes else 0 + assert verse_vel < chorus_vel, \ + f"Verse velocity ({verse_vel}) should be less than chorus ({chorus_vel})" + + def test_drumloop_assignments_no_break_key(self): + """DRUMLOOP_ASSIGNMENTS has no 'break' key — replaced by activity matrix.""" + from scripts.compose import DRUMLOOP_ASSIGNMENTS + assert "break" not in DRUMLOOP_ASSIGNMENTS + assert "pre-chorus" in DRUMLOOP_ASSIGNMENTS + + +class TestSidechainBassCC: + """Integration tests for CC11 sidechain ducking on 808 bass.""" + + def test_bass_track_populates_midi_cc_with_kick_cache(self): + """build_bass_track populates midi_cc when kick cache present.""" + from scripts.compose import build_bass_track + from src.core.schema import SectionDef + + sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)] + offsets = [0.0] + kick_cache = {"fake_drumloop.wav": [1.0, 3.0, 5.0, 7.0]} + + track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache) + assert len(track.clips) > 0, "Bass should have clips" + + verse_clip = track.clips[0] + # With 4 kicks in range, each generates 3 CC events + assert len(verse_clip.midi_cc) == 12, f"Expected 12 CC events (4 kicks × 3), got {len(verse_clip.midi_cc)}" + + # Check first kick's duck events + cc_times = [(cc.time, cc.value) for cc in verse_clip.midi_cc[:3]] + assert (1.0, 50) in cc_times, f"Expected CC dip at 1.0, got {cc_times}" + assert (1.02, 50) in cc_times, f"Expected CC hold at 1.02, got {cc_times}" + assert (1.18, 127) in cc_times, f"Expected CC release at 1.18, got {cc_times}" + + def test_bass_track_no_kick_cache_empty_cc(self): + """build_bass_track produces empty midi_cc when no kick cache provided.""" + from scripts.compose import build_bass_track + from src.core.schema import SectionDef + + sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)] + offsets = [0.0] + + track = build_bass_track(sections, offsets, "A", True) + assert len(track.clips) > 0 + verse_clip = track.clips[0] + assert verse_clip.midi_cc == [], "midi_cc should be empty without kick cache" + + def test_bass_track_no_kicks_in_range(self): + """build_bass_track produces empty midi_cc when no kicks in clip range.""" + from scripts.compose import build_bass_track + from src.core.schema import SectionDef + + sections = [SectionDef(name="verse", bars=2, energy=0.5, velocity_mult=0.7)] + offsets = [0.0] + # Kicks at beats far outside the clip range (0-8 beats) + kick_cache = {"fake_drumloop.wav": [100.0, 200.0]} + + track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache) + verse_clip = track.clips[0] + assert verse_clip.midi_cc == [], "midi_cc should be empty when kicks are outside clip range" + + def test_bass_track_preserves_notes_with_cc(self): + """build_bass_track preserves existing note generation when CC added.""" + from scripts.compose import build_bass_track + from src.core.schema import SectionDef + + sections = [SectionDef(name="verse", bars=4, energy=0.5, velocity_mult=0.7)] + offsets = [0.0] + kick_cache = {"fake_drumloop.wav": [2.0]} + + track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache) + verse_clip = track.clips[0] + # Should still have bass notes (i - iv pattern for 4 bars) + assert len(verse_clip.midi_notes) > 0, "Bass notes should still be generated" + assert len(verse_clip.midi_cc) == 3, "Should have 3 CC events for 1 kick in range" + + def test_bass_track_kicks_relative_to_clip(self): + """build_bass_track produces CC times relative to clip start, not absolute.""" + from scripts.compose import build_bass_track + from src.core.schema import SectionDef + + # Section at offset 2 bars (8 beats) + sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)] + offsets = [2.0] # starts at bar 2 = beat 8 + + # Kick at absolute beat 9.0 → relative beat 1.0 + kick_cache = {"fake_drumloop.wav": [9.0]} + + track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache) + verse_clip = track.clips[0] + assert len(verse_clip.midi_cc) == 3 + + # CC times should be relative to clip start (9.0 - 8.0 = 1.0) + first_cc_time = verse_clip.midi_cc[0].time + assert first_cc_time == 1.0, f"CC time should be 1.0 (relative), got {first_cc_time}" diff --git a/tests/test_core_schema.py b/tests/test_core_schema.py index e6d771d..d87e2d0 100644 --- a/tests/test_core_schema.py +++ b/tests/test_core_schema.py @@ -1,12 +1,60 @@ -"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote.""" +"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote, CCEvent.""" +import dataclasses import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1])) import pytest -from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote +from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent + + +class TestCCEvent: + """Test CCEvent dataclass (Phase 1: schema).""" + + def test_ccevent_round_trip(self): + """CCEvent round-trips correctly through dataclass construction.""" + evt = CCEvent(controller=11, time=0.5, value=50) + assert evt.controller == 11 + assert evt.time == 0.5 + assert evt.value == 50 + + def test_ccevent_defaults(self): + """CCEvent has no default fields — all are required.""" + from dataclasses import fields as dc_fields + field_names = [f.name for f in dc_fields(CCEvent)] + no_default = [] + for f in dc_fields(CCEvent): + if f.default is f.default_factory is dataclasses.MISSING: + no_default.append(f.name) + assert "controller" in no_default + assert "time" in no_default + assert "value" in no_default + + def test_clipdef_with_midi_cc(self): + """ClipDef with midi_cc field.""" + cc = [CCEvent(11, 0.0, 50), CCEvent(11, 0.18, 127)] + clip = ClipDef(position=0.0, length=16.0, name="Test", midi_cc=cc) + assert len(clip.midi_cc) == 2 + assert clip.midi_cc[0].controller == 11 + assert clip.midi_cc[0].time == 0.0 + assert clip.midi_cc[0].value == 50 + + def test_clipdef_midi_cc_default_is_empty(self): + """ClipDef.midi_cc defaults to empty list.""" + clip = ClipDef(position=0.0, length=16.0, name="Test") + assert clip.midi_cc == [] + + def test_song_validate_empty_midi_cc(self): + """Song.validate() passes with empty midi_cc (no regression).""" + meta = SongMeta(bpm=95, key="Am") + note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) + clip = ClipDef(position=0.0, length=16.0, name="Test", midi_notes=[note], midi_cc=[]) + track = TrackDef(name="Test", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + errors = song.validate() + assert errors == [] class TestSongDefinitionInstantiation: @@ -135,3 +183,30 @@ class TestMidiNote: """MidiNote does NOT clamp — accepts any int (caller's responsibility).""" note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200) assert note.velocity == 200 + + +class TestClipDefVolMult: + """Test ClipDef.vol_mult default and behavior.""" + + def test_vol_mult_default_is_one(self): + """ClipDef.vol_mult defaults to 1.0.""" + clip = ClipDef(position=0.0, length=16.0, name="Test") + assert clip.vol_mult == 1.0 + + def test_vol_mult_custom_value(self): + """ClipDef.vol_mult accepts custom value.""" + clip = ClipDef(position=0.0, length=16.0, name="Test", vol_mult=0.7) + assert clip.vol_mult == 0.7 + + def test_audio_clip_vol_mult_default_is_one(self): + """Audio clip with default vol_mult=1.0 has no D_VOL side effect.""" + clip = ClipDef(position=0.0, length=16.0, audio_path="test.wav", vol_mult=1.0) + assert clip.is_audio + assert clip.vol_mult == 1.0 + + def test_midi_clip_vol_mult_default_is_one(self): + """MIDI clip with default vol_mult=1.0 has no velocity scaling.""" + note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) + clip = ClipDef(position=0.0, length=16.0, midi_notes=[note], vol_mult=1.0) + assert clip.is_midi + assert clip.vol_mult == 1.0 diff --git a/tests/test_melody_engine.py b/tests/test_melody_engine.py new file mode 100644 index 0000000..8e53178 --- /dev/null +++ b/tests/test_melody_engine.py @@ -0,0 +1,448 @@ +"""Unit tests for src/composer/melody_engine.py — hook-based melody generation.""" + +import pytest +from src.composer.melody_engine import ( + build_motif, + apply_variation, + build_call_response, + _resolve_chord_tones, + _resolve_tension_notes, + _resolve_tonic, + _get_pentatonic, + _get_diatonic, +) + + +# --------------------------------------------------------------------------- +# Phase 3.1 — Determinism +# --------------------------------------------------------------------------- + +class TestMotifDeterministic: + """Same seed → identical output.""" + + def test_motif_deterministic_hook(self): + a = build_motif("A", True, "hook", 4, 42) + b = build_motif("A", True, "hook", 4, 42) + assert a == b, "Same seed must produce identical motfs" + assert len(a) > 0, "Hook should produce notes" + + def test_motif_deterministic_stabs(self): + a = build_motif("A", True, "stabs", 2, 1) + b = build_motif("A", True, "stabs", 2, 1) + assert a == b + + def test_motif_deterministic_smooth(self): + a = build_motif("A", True, "smooth", 4, 7) + b = build_motif("A", True, "smooth", 4, 7) + assert a == b + + def test_motif_deterministic_different_style(self): + hook = build_motif("A", True, "hook", 4, 42) + stabs = build_motif("A", True, "stabs", 4, 42) + assert hook != stabs, "Different styles should produce different output" + + +# --------------------------------------------------------------------------- +# Phase 3.2 — Different seeds +# --------------------------------------------------------------------------- + +class TestMotifDifferentSeeds: + """Different seeds → different output.""" + + def test_motif_different_seeds_hook(self): + a = build_motif("A", True, "hook", 4, 42) + b = build_motif("A", True, "hook", 4, 99) + assert a != b, "Different seeds must produce different output" + + def test_motif_different_seeds_stabs(self): + a = build_motif("A", True, "stabs", 4, 1) + b = build_motif("A", True, "stabs", 4, 2) + assert a != b + + def test_motif_different_seeds_smooth(self): + a = build_motif("A", True, "smooth", 4, 1) + b = build_motif("A", True, "smooth", 4, 2) + assert a != b + + +# --------------------------------------------------------------------------- +# Phase 3.3 — Invalid style +# --------------------------------------------------------------------------- + +class TestInvalidStyle: + """Invalid style raises ValueError.""" + + def test_invalid_style_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid style"): + build_motif("A", True, "invalid", 4, 42) + + def test_value_error_mentions_valid_styles(self): + with pytest.raises(ValueError) as exc: + build_motif("A", True, "xyz", 4, 42) + msg = str(exc.value) + assert "hook" in msg + assert "stabs" in msg + assert "smooth" in msg + + +# --------------------------------------------------------------------------- +# Phase 3.4 — Chord tones on strong beats (hook) +# --------------------------------------------------------------------------- + +class TestHookChordTones: + """Hook style: ≥70% of quarter-position notes are chord tones.""" + + @staticmethod + def _quarter_position_notes(notes): + """Return notes whose start time is on a quarter-beat boundary.""" + return [n for n in notes if abs(n.start % 1.0) < 0.001] + + @staticmethod + def _is_chord_tone(pitch, key_root, key_minor, bar, bar_offset=0): + """Check if pitch belongs to the active chord at the given bar.""" + chord_tones = _resolve_chord_tones(key_root, key_minor, bar) + return any(abs(pitch - ct) % 12 == 0 for ct in chord_tones) + + def test_hook_chord_tones_on_strong_beats(self): + """≥70% of notes on quarter positions are chord tones.""" + notes = build_motif("A", True, "hook", 8, 42) + quarter_notes = self._quarter_position_notes(notes) + assert len(quarter_notes) > 0, "Hook must have notes on quarter positions" + + chord_count = 0 + for note in quarter_notes: + bar = int(note.start // 4.0) + if self._is_chord_tone(note.pitch, "A", True, bar): + chord_count += 1 + + ratio = chord_count / len(quarter_notes) + assert ratio >= 0.70, ( + f"Chord tone ratio on strong beats: {ratio:.1%}, need ≥ 70%\n" + f"Pitches: {[n.pitch for n in quarter_notes]}" + ) + + def test_hook_produces_notes(self): + """Hook should produce a reasonable number of notes.""" + notes = build_motif("A", True, "hook", 4, 42) + assert 16 <= len(notes) <= 24, ( + f"Expected 16-24 notes for 4-bar hook, got {len(notes)}" + ) + + +# --------------------------------------------------------------------------- +# Phase 3.5 — Stabs grid alignment +# --------------------------------------------------------------------------- + +class TestStabsGridAlignment: + """Stabs: all notes on dembow positions [1.0, 2.5, 3.0, 3.5] per bar.""" + + DEMBOW = {1.0, 2.5, 3.0, 3.5} + + def test_stabs_grid_alignment(self): + notes = build_motif("A", True, "stabs", 4, 1) + assert len(notes) > 0, "Stabs must produce notes" + + for note in notes: + bar_start = int(note.start // 4.0) * 4.0 + pos_in_bar = note.start - bar_start + # Allow tiny floating-point tolerance + assert any( + abs(pos_in_bar - dp) < 0.001 for dp in self.DEMBOW + ), f"Note at {note.start} (pos_in_bar={pos_in_bar}) not on dembow grid" + + def test_stabs_duration_16th(self): + """All stabs should be 16th notes (≤ 0.25 beats).""" + notes = build_motif("A", True, "stabs", 4, 1) + for note in notes: + assert note.duration <= 0.25, ( + f"Stab duration {note.duration} > 16th note" + ) + + +# --------------------------------------------------------------------------- +# Phase 3.6 — Smooth stepwise motion +# --------------------------------------------------------------------------- + +class TestSmoothStepwise: + """Smooth style: consecutive notes differ by ≤ 2 semitones.""" + + def test_smooth_stepwise_motion(self): + notes = build_motif("A", True, "smooth", 4, 7) + assert len(notes) >= 8, f"Expected at least 8 notes, got {len(notes)}" + + sorted_notes = sorted(notes, key=lambda n: n.start) + for i in range(len(sorted_notes) - 1): + diff = abs(sorted_notes[i + 1].pitch - sorted_notes[i].pitch) + assert diff <= 2, ( + f"Step from pitch {sorted_notes[i].pitch} to {sorted_notes[i + 1].pitch} " + f"at beat {sorted_notes[i + 1].start}: diff={diff} > 2" + ) + + def test_smooth_eighth_note_density(self): + """Smooth style should produce notes at roughly eighth-note spacing.""" + notes = build_motif("A", True, "smooth", 4, 7) + sorted_notes = sorted(notes, key=lambda n: n.start) + + # Each note should be ~0.5 beats apart (eighth note) + # Check that most gaps are close to 0.5 + gaps = [] + for i in range(len(sorted_notes) - 1): + gap = sorted_notes[i + 1].start - sorted_notes[i].start + gaps.append(gap) + + avg_gap = sum(gaps) / len(gaps) if gaps else 0 + assert 0.4 < avg_gap < 0.6, ( + f"Expected eighth-note spacing (~0.5), got avg gap {avg_gap:.3f}" + ) + + +# --------------------------------------------------------------------------- +# Phase 3.7 — Variation preserves structure +# --------------------------------------------------------------------------- + +class TestVariation: + """apply_variation() preserves note count, durations, and IOIs.""" + + def test_variation_preserves_note_count(self): + motif = build_motif("A", True, "hook", 4, 42) + variant = apply_variation(motif, shift_beats=0.25) + assert len(variant) == len(motif) + + def test_variation_preserves_durations(self): + motif = build_motif("A", True, "hook", 4, 42) + variant = apply_variation(motif, shift_beats=0.25, transpose_semitones=3) + for orig, var in zip(motif, variant): + assert var.duration == orig.duration, ( + f"Duration mismatch: {var.duration} != {orig.duration}" + ) + + def test_variation_preserves_iois(self): + """Inter-onset intervals are preserved after shift.""" + motif = sorted(build_motif("A", True, "hook", 4, 42), key=lambda n: n.start) + variant = sorted( + apply_variation(motif, shift_beats=0.25), + key=lambda n: n.start, + ) + + for i in range(len(motif) - 1): + orig_ioi = motif[i + 1].start - motif[i].start + var_ioi = variant[i + 1].start - variant[i].start + assert abs(orig_ioi - var_ioi) < 0.001, ( + f"IOI mismatch at index {i}: {var_ioi:.4f} != {orig_ioi:.4f}" + ) + + def test_variation_shifts_start_times(self): + motif = build_motif("A", True, "hook", 4, 42) + variant = apply_variation(motif, shift_beats=0.25) + for orig, var in zip( + sorted(motif, key=lambda n: n.start), + sorted(variant, key=lambda n: n.start), + ): + assert abs(var.start - orig.start - 0.25) < 0.001 + + def test_variation_transposes_pitches(self): + motif = build_motif("A", True, "hook", 4, 42) + variant = apply_variation(motif, transpose_semitones=3) + for orig, var in zip(motif, variant): + assert var.pitch == orig.pitch + 3 + + def test_variation_empty_motif(self): + result = apply_variation([], shift_beats=1.0) + assert result == [] + + def test_variation_defaults(self): + motif = build_motif("A", True, "hook", 4, 42) + variant = apply_variation(motif) + assert len(variant) == len(motif) + + +# --------------------------------------------------------------------------- +# Phase 3.8 — Call ends on tension, response on tonic +# --------------------------------------------------------------------------- + +class TestCallResponseResolution: + """build_call_response(): call → V/VII, response → tonic.""" + + def test_call_ends_on_tension_response_ends_on_tonic(self): + """Call (first half) last note = V or VII; response last note = tonic.""" + # Am: tonic = A(69), V = E(76), VII = G(79) + motif = build_motif("A", True, "hook", 4, 42) + result = build_call_response(motif, bars=8, key_root="A", key_minor=True, seed=42) + assert len(result) > 0 + + # Sort by start time + sorted_notes = sorted(result, key=lambda n: n.start) + + # Find last note of first 4 bars (call) + call_cutoff = 4.0 * 4 # 4 bars * 4 beats + call_notes = [n for n in sorted_notes if n.start < call_cutoff] + assert len(call_notes) > 0, "No notes in call half" + last_call_pitch = call_notes[-1].pitch % 12 + + # V of Am is E (pitch%12=4), VII is G (pitch%12=7) + assert last_call_pitch in (4, 7), ( + f"Last call note pitch class {last_call_pitch} must be V(4=E) or VII(7=G)" + ) + + # Last note overall (response) must be tonic A (pitch%12=9) + last_note = sorted_notes[-1].pitch % 12 + assert last_note == 9, ( + f"Last note pitch class {last_note} must be tonic A(9)" + ) + + +# --------------------------------------------------------------------------- +# Phase 3.9 — Call-response fills bars with motif repetition +# --------------------------------------------------------------------------- + +class TestCallResponseFillsBars: + """build_call_response() fills section with motif repetition.""" + + def test_call_response_fills_bars(self): + """A 2-bar motif repeated to fill 8 bars.""" + motif = build_motif("A", True, "hook", 2, 42) + result = build_call_response(motif, bars=8, key_root="A", key_minor=True, seed=42) + + # Total span should be ~8 bars (32 beats) + max_end = max(n.start + n.duration for n in result) if result else 0 + min_start = min(n.start for n in result) if result else 0 + span = max_end - min_start + assert span >= 28, f"Notes should span ~32 beats (8 bars), got {span}" + + # Motif content should repeat at least 2 times within 8 bars + assert len(result) >= len(motif) * 2, ( + f"Motif repeats: expected ≥{len(motif)*2} notes, got {len(result)}" + ) + + def test_call_response_empty_motif(self): + result = build_call_response([], bars=8, key_root="A", key_minor=True) + assert result == [] + + def test_call_response_length_matches_bars(self): + """Result should not exceed `bars` worth of material.""" + motif = build_motif("A", True, "hook", 4, 42) + for test_bars in (2, 4, 8): + result = build_call_response(motif, bars=test_bars, + key_root="A", key_minor=True, seed=42) + max_end = max((n.start + n.duration for n in result), default=0) + assert max_end <= test_bars * 4.0 + 0.001, ( + f"For {test_bars} bars, max_end={max_end} exceeds {test_bars * 4.0}" + ) + + +# --------------------------------------------------------------------------- +# Internal helpers tests +# --------------------------------------------------------------------------- + +class TestResolveChordTones: + """_resolve_chord_tones returns correct pitches.""" + + def test_chord_tones_am_bar0(self): + """Bar 0 of Am should return Am chord tones (A, C, E).""" + tones = _resolve_chord_tones("A", True, 0, 4) + # Check for pitch classes 9(A), 0(C), 4(E) + pitch_classes = {p % 12 for p in tones} + assert 9 in pitch_classes, "A missing" + assert 0 in pitch_classes, "C missing" + assert 4 in pitch_classes, "E missing" + + def test_chord_tones_am_bar2(self): + """Bar 2 of Am should return F major (F, A, C) — the VI chord.""" + tones = _resolve_chord_tones("A", True, 2, 4) + pitch_classes = {p % 12 for p in tones} + assert 5 in pitch_classes, "F missing" # F = 5 + assert 9 in pitch_classes, "A missing" # A = 9 + assert 0 in pitch_classes, "C missing" # C = 0 + + def test_chord_tones_wraps(self): + """Bar 8 wraps back to chord i.""" + tones0 = _resolve_chord_tones("A", True, 0, 4) + tones8 = _resolve_chord_tones("A", True, 8, 4) + p0 = {p % 12 for p in tones0} + p8 = {p % 12 for p in tones8} + assert p0 == p8, "Bar 0 and bar 8 should have same chord tones (wrapped)" + + +class TestResolveTensionNotes: + """_resolve_tension_notes returns correct V and VII.""" + + def test_tension_notes_am(self): + v_pitch, vii_pitch = _resolve_tension_notes("A", True, 4) + # V of A = E (MIDI 69 + 7 = 76) + assert v_pitch == 76, f"V of Am should be E (76), got {v_pitch}" + # VII of Am minor = G (MIDI 69 + 10 = 79) + assert vii_pitch == 79, f"VII of Am should be G (79), got {vii_pitch}" + + def test_tension_notes_cm(self): + v_pitch, vii_pitch = _resolve_tension_notes("C", True, 4) + # C4=60, V=G4=60+7=67 + assert v_pitch == 67, f"V of Cm should be G4 (67), got {v_pitch}" + # VII of C minor = Bb4 = 60+10=70 + assert vii_pitch == 70, f"VII of Cm should be Bb4 (70), got {vii_pitch}" + + +class TestResolveTonic: + """_resolve_tonic returns correct pitch.""" + + def test_tonic_am(self): + assert _resolve_tonic("A", 4) == 69 # A4 + + def test_tonic_dm(self): + assert _resolve_tonic("D", 4) == 62 # D4 + + +class TestScaleHelpers: + """_get_pentatonic and _get_diatonic.""" + + def test_pentatonic_am(self): + notes = _get_pentatonic("A", True, 4) + pitch_classes = {n % 12 for n in notes} + assert pitch_classes == {9, 0, 2, 4, 7}, ( + f"Am pentatonic: A C D E G, got {pitch_classes}" + ) + + def test_pentatonic_c_major(self): + notes = _get_pentatonic("C", False, 4) + pitch_classes = {n % 12 for n in notes} + assert pitch_classes == {0, 2, 4, 7, 9}, ( + f"C major pentatonic: C D E G A, got {pitch_classes}" + ) + + def test_diatonic_am(self): + notes = _get_diatonic("A", True, 4) + pitch_classes = {n % 12 for n in notes} + assert pitch_classes == {9, 11, 0, 2, 4, 5, 7}, ( + f"Am natural minor: A B C D E F G, got {pitch_classes}" + ) + + def test_diatonic_c_major(self): + notes = _get_diatonic("C", False, 4) + pitch_classes = {n % 12 for n in notes} + assert pitch_classes == {0, 2, 4, 5, 7, 9, 11}, ( + f"C major: C D E F G A B, got {pitch_classes}" + ) + + +# --------------------------------------------------------------------------- +# Cross-style tests +# --------------------------------------------------------------------------- + +class TestCrossStyle: + """Tests covering all three styles.""" + + def test_all_styles_return_midi_notes(self): + for style in ("hook", "stabs", "smooth"): + notes = build_motif("A", True, style, 4, 0) + assert isinstance(notes, list) + assert len(notes) > 0, f"Style '{style}' returned empty list" + assert all(hasattr(n, "pitch") for n in notes) + assert all(hasattr(n, "start") for n in notes) + + def test_different_key_produces_different_output(self): + am = build_motif("A", True, "hook", 4, 42) + dm = build_motif("D", True, "hook", 4, 42) + assert am != dm, "Different keys should produce different motifs" + + def test_major_key_produces_notes(self): + notes = build_motif("C", False, "hook", 4, 42) + assert len(notes) > 0 diff --git a/tests/test_preset_transform.py b/tests/test_preset_transform.py new file mode 100644 index 0000000..04698c3 --- /dev/null +++ b/tests/test_preset_transform.py @@ -0,0 +1,194 @@ +"""Tests for role-aware preset system (presets-pack). + +Covers PresetTransformer, role-aware PLUGIN_PRESETS lookup, +and thread-through via make_plugin → PluginDef.role → _build_plugin. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from src.reaper_builder.preset_transformer import PresetTransformer +from src.reaper_builder import PLUGIN_PRESETS, PLUGIN_REGISTRY + + +# --------------------------------------------------------------------------- +# PresetTransformer unit tests +# --------------------------------------------------------------------------- + +class TestPresetTransformer: + """PresetTransformer derives role-specific preset data.""" + + def test_derive_serum_returns_list(self): + """derive() for Serum_2 returns a list of chunks.""" + default = PLUGIN_PRESETS.get(("Serum_2", "")) + assert default is not None, "Serum_2 default preset must exist" + result = PresetTransformer.derive("Serum_2", default, "bass") + assert isinstance(result, list) + assert len(result) == len(default) + assert all(isinstance(c, str) for c in result) + + def test_derive_decapitator_returns_list(self): + """derive() for Decapitator returns a list of chunks.""" + default = PLUGIN_PRESETS.get(("Decapitator", "")) + assert default is not None, "Decapitator default preset must exist" + result = PresetTransformer.derive("Decapitator", default, "drums") + assert isinstance(result, list) + assert len(result) == len(default) + + def test_derive_omnisphere_returns_list(self): + """derive() for Omnisphere returns a list of chunks.""" + default = PLUGIN_PRESETS.get(("Omnisphere", "")) + assert default is not None, "Omnisphere default preset must exist" + result = PresetTransformer.derive("Omnisphere", default, "pad") + assert isinstance(result, list) + assert len(result) == len(default) + + def test_derive_unknown_plugin_returns_default(self): + """derive() for unsupported plugin returns original chunks.""" + chunks = ["mock_chunk_1", "mock_chunk_2"] + result = PresetTransformer.derive("NonexistentPlugin", chunks, "lead") + assert result == chunks + + def test_derive_preserves_chunk_count(self): + """derive() output has same number of chunks as input.""" + for plugin in ["Serum_2", "Decapitator", "Omnisphere"]: + default = PLUGIN_PRESETS.get((plugin, "")) + if not default: + continue + for role in ["bass", "lead", "drums", "pad"]: + result = PresetTransformer.derive(plugin, default, role) + assert len(result) == len(default), ( + f"{plugin}/{role} chunk count mismatch: " + f"got {len(result)}, expected {len(default)}" + ) + + +# --------------------------------------------------------------------------- +# Role-aware preset structure tests +# --------------------------------------------------------------------------- + +class TestRoleAwarePresets: + """PLUGIN_PRESETS is structured as {(plugin, role): chunks}.""" + + def test_default_role_entries_exist(self): + """All known plugins have a "" (default) role entry.""" + flat_keys = set() + for (name, role), _ in PLUGIN_PRESETS.items(): + if role == "": + flat_keys.add(name) + # At minimum, the multi-role targets must have defaults + for name in ["Serum_2", "Decapitator", "Omnisphere"]: + assert name in flat_keys, f"{name} must have default role entry" + + def test_role_specific_entries_exist(self): + """Multi-role plugins have their role-specific entries.""" + roles = { + "Serum_2": ["bass", "lead"], + "Decapitator": ["drumloop", "bass", "clap", "perc"], + "Omnisphere": ["chords", "pad"], + } + for plugin, expected_roles in roles.items(): + for role in expected_roles: + assert (plugin, role) in PLUGIN_PRESETS, ( + f"Missing ({plugin}, {role}) in PLUGIN_PRESETS" + ) + + def test_role_entries_non_empty(self): + """Role-specific entries contain non-empty preset data.""" + for (name, role), chunks in PLUGIN_PRESETS.items(): + if role != "" and name in ("Serum_2", "Decapitator", "Omnisphere"): + assert len(chunks) > 0, ( + f"({name}, {role}) has empty preset data" + ) + + def test_unknown_role_falls_back_to_default(self): + """Role not present in PLUGIN_PRESETS → fall back to default.""" + # Simulate: a plugin has only default entry, no "pad" role + # The lookup should return the "" entry + from src.reaper_builder import _resolve_preset + + # Gullfoss_Master doesn't have multi-role entries, only "" + result = _resolve_preset("Gullfoss_Master", "pad") + default = PLUGIN_PRESETS.get(("Gullfoss_Master", "")) + assert result == default, ( + "Unknown role should fall back to default preset" + ) + + +# --------------------------------------------------------------------------- +# Integration: make_plugin + role threading +# --------------------------------------------------------------------------- + +class TestMakePluginRoleThreading: + """make_plugin with role correctly sets PluginDef.role and preset_data.""" + + def test_make_plugin_with_role_sets_role_field(self): + """make_plugin(key, idx, role=...) sets PluginDef.role.""" + from scripts.compose import make_plugin + + p = make_plugin("Serum_2", 0, role="bass") + assert p.role == "bass" + assert p.preset_data is not None + + def test_make_plugin_without_role_defaults_to_empty(self): + """make_plugin(key, idx) without role sets role="" (backward compat).""" + from scripts.compose import make_plugin + + p = make_plugin("Decapitator", 0) + assert p.role == "" + + def test_make_plugin_different_roles_lookup_correct_entries(self): + """Different roles resolve to their respective PLUGIN_PRESETS entries.""" + from scripts.compose import make_plugin + + # Both should return data — in MVP they're identical but structure is correct + p_bass = make_plugin("Serum_2", 0, role="bass") + p_lead = make_plugin("Serum_2", 0, role="lead") + + # Both should have non-None preset data + assert p_bass.preset_data is not None, "bass role should have preset_data" + assert p_lead.preset_data is not None, "lead role should have preset_data" + + # Both should be lists with content + assert isinstance(p_bass.preset_data, list) + assert isinstance(p_lead.preset_data, list) + assert len(p_bass.preset_data) > 0 + assert len(p_lead.preset_data) > 0 + + def test_make_plugin_unknown_role_falls_back(self): + """Unknown role returns preset from "" entry (if available).""" + from scripts.compose import make_plugin + + # "pad" role is not valid for Serum_2 (Omnisphere handles pad) + p = make_plugin("Serum_2", 0, role="pad") + # Should still get the default Serum_2 preset + assert p.preset_data is not None + assert p.role == "pad" + + +# --------------------------------------------------------------------------- +# Backward compatibility +# --------------------------------------------------------------------------- + +class TestBackwardCompatibility: + """Existing behavior preserved with no role.""" + + def test_make_plugin_known_key_still_works(self): + """make_plugin with known registry key (no role) works as before.""" + from scripts.compose import make_plugin + p = make_plugin("Decapitator", 0) + assert p.name == "Decapitator" + assert p.index == 0 + assert p.role == "" + + def test_make_plugin_unknown_key_still_works(self): + """make_plugin with unknown key (no role) returns PluginDef.""" + from scripts.compose import make_plugin + p = make_plugin("NonExistent", 2) + assert p.name == "NonExistent" + assert p.index == 2 + assert p.role == "" diff --git a/tests/test_reaper_builder.py b/tests/test_reaper_builder.py index fd3f69f..55fc169 100644 --- a/tests/test_reaper_builder.py +++ b/tests/test_reaper_builder.py @@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parents[1])) import pytest import tempfile -from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef +from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef, CCEvent from src.reaper_builder import RPPBuilder @@ -445,9 +445,9 @@ class TestVST3PresetData: # Check that plugins WITH preset data have that data in output from src.reaper_builder import PLUGIN_PRESETS, VST3_REGISTRY vst3_keys = set(VST3_REGISTRY.keys()) - for name, preset_lines in PLUGIN_PRESETS.items(): - # Only check VST3 plugins (skip VST2 plugins which are in the same dict now) - if name not in vst3_keys: + for (name, role), preset_lines in PLUGIN_PRESETS.items(): + # Only check VST3 plugins and default role (backward compat) + if name not in vst3_keys or role != "": continue if len(preset_lines) > 0: # Check first preset line — most distinctive, no collision risk @@ -455,3 +455,168 @@ class TestVST3PresetData: assert first_line in content, f"{name} preset line not found in output" finally: Path(tmp_path).unlink(missing_ok=True) + + +class TestDVolEmission: + """Test D_VOL emission for audio clips with vol_mult.""" + + def test_audio_clip_vol_mult_not_one_emits_dvol(self): + """Audio clip with vol_mult=0.7 emits D_VOL in ITEM.""" + meta = SongMeta(bpm=95, key="Am") + clip = ClipDef( + position=0.0, length=16.0, name="Test", + audio_path="C:/test.wav", vol_mult=0.7, + ) + track = TrackDef(name="Test Track", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + builder = RPPBuilder(song) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rpp", delete=False, encoding="utf-8" + ) as f: + tmp_path = f.name + + try: + builder.write(tmp_path) + content = Path(tmp_path).read_text(encoding="utf-8") + assert "D_VOL 0.7" in content, "D_VOL line expected for vol_mult=0.7" + finally: + Path(tmp_path).unlink(missing_ok=True) + + def test_audio_clip_default_vol_mult_emits_no_dvol(self): + """Audio clip with default vol_mult=1.0 emits NO D_VOL.""" + meta = SongMeta(bpm=95, key="Am") + clip = ClipDef( + position=0.0, length=16.0, name="Test", + audio_path="C:/test.wav", # default vol_mult=1.0 + ) + track = TrackDef(name="Test Track", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + builder = RPPBuilder(song) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rpp", delete=False, encoding="utf-8" + ) as f: + tmp_path = f.name + + try: + builder.write(tmp_path) + content = Path(tmp_path).read_text(encoding="utf-8") + assert "D_VOL" not in content, "No D_VOL expected for default vol_mult" + finally: + Path(tmp_path).unlink(missing_ok=True) + + def test_midi_clip_vol_mult_scales_velocity(self): + """MIDI clip vol_mult scales velocity in output E lines.""" + meta = SongMeta(bpm=95, key="Am") + note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) + clip = ClipDef( + position=0.0, length=16.0, name="Test MIDI", + midi_notes=[note], vol_mult=0.5, + ) + track = TrackDef(name="MIDI Track", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + builder = RPPBuilder(song) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rpp", delete=False, encoding="utf-8" + ) as f: + tmp_path = f.name + + try: + builder.write(tmp_path) + content = Path(tmp_path).read_text(encoding="utf-8") + # Velocity should be 100 * 0.5 = 50 = 0x32 + # The E line format: E 90 + # pitch 60 = 0x3c, velocity 50 = 0x32 + assert "90 3c 32" in content, f"Expected velocity 50 (0x32) in E line, got content fragment" + finally: + Path(tmp_path).unlink(missing_ok=True) + + +class TestCCEmission: + """Test that CC11 events are emitted as B0 0B E-lines.""" + + def test_midi_source_with_cc_events(self): + """_build_midi_source emits B0 0B lines for clips with midi_cc.""" + meta = SongMeta(bpm=95, key="Am") + note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100) + cc = CCEvent(controller=11, time=0.0, value=50) + clip = ClipDef( + position=0.0, length=16.0, name="Test", + midi_notes=[note], midi_cc=[cc], + ) + track = TrackDef(name="MIDI CC", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + builder = RPPBuilder(song) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rpp", delete=False, encoding="utf-8" + ) as f: + tmp_path = f.name + + try: + builder.write(tmp_path) + content = Path(tmp_path).read_text(encoding="utf-8") + # CC event at time 0, controller 11, value 50 = 0x32 + assert "B0 0b 32" in content, f"Expected CC11 B0 0b line, got: {content}" + finally: + Path(tmp_path).unlink(missing_ok=True) + + def test_midi_source_cc_note_interleaved_order(self): + """CC events interleaved with notes in time order.""" + meta = SongMeta(bpm=95, key="Am") + note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100) + cc1 = CCEvent(controller=11, time=0.0, value=50) + cc2 = CCEvent(controller=11, time=0.25, value=127) + clip = ClipDef( + position=0.0, length=16.0, name="Test", + midi_notes=[note], midi_cc=[cc1, cc2], + ) + track = TrackDef(name="MIDI CC", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + builder = RPPBuilder(song) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rpp", delete=False, encoding="utf-8" + ) as f: + tmp_path = f.name + + try: + builder.write(tmp_path) + content = Path(tmp_path).read_text(encoding="utf-8") + # Find E-lines — CC should come before note in time order + e_lines = [l.strip() for l in content.split('\n') if l.strip().startswith('E ')] + # First E-line should be CC at time 0 (B0 0b) + assert any('B0' in l for l in e_lines), f"No CC E-lines found in {e_lines}" + # Find position of first CC line vs first note line + cc_indices = [i for i, l in enumerate(e_lines) if 'B0' in l] + note_indices = [i for i, l in enumerate(e_lines) if '90' in l] + assert cc_indices[0] < note_indices[0], \ + f"CC at time 0 should come before note at time 0.5. E-lines: {e_lines}" + finally: + Path(tmp_path).unlink(missing_ok=True) + + def test_midi_source_no_cc_when_empty(self): + """_build_midi_source emits no B0 lines when midi_cc is empty.""" + meta = SongMeta(bpm=95, key="Am") + note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) + clip = ClipDef( + position=0.0, length=16.0, name="Test", + midi_notes=[note], midi_cc=[], + ) + track = TrackDef(name="MIDI No CC", clips=[clip]) + song = SongDefinition(meta=meta, tracks=[track]) + builder = RPPBuilder(song) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rpp", delete=False, encoding="utf-8" + ) as f: + tmp_path = f.name + + try: + builder.write(tmp_path) + content = Path(tmp_path).read_text(encoding="utf-8") + assert "B0" not in content, f"No CC expected, got: {content}" + finally: + Path(tmp_path).unlink(missing_ok=True) diff --git a/tests/test_section_builder.py b/tests/test_section_builder.py index 1162706..8b2480c 100644 --- a/tests/test_section_builder.py +++ b/tests/test_section_builder.py @@ -33,6 +33,152 @@ class TestSectionDef: assert section.vol_mult == 0.6 +class TestBuildSectionStructureMultipliers: + """Verify build_section_structure() populates velocity_mult and vol_mult.""" + + @staticmethod + def _get_sections(): + from scripts.compose import build_section_structure + sections, _offsets = build_section_structure() + return {s.name: s for s in sections} + + def test_intro_has_low_multipliers(self): + sections = self._get_sections() + assert sections["intro"].velocity_mult == 0.6 + assert sections["intro"].vol_mult == 0.70 + + def test_verse_has_mid_multipliers(self): + sections = self._get_sections() + assert sections["verse"].velocity_mult == 0.7 + assert sections["verse"].vol_mult == 0.85 + + def test_pre_chorus_has_high_multipliers(self): + sections = self._get_sections() + assert sections["pre-chorus"].velocity_mult == 0.85 + assert sections["pre-chorus"].vol_mult == 0.95 + + def test_chorus_has_full_multipliers(self): + sections = self._get_sections() + assert sections["chorus"].velocity_mult == 1.0 + assert sections["chorus"].vol_mult == 1.0 + + def test_verse2_same_as_verse(self): + sections = self._get_sections() + assert sections["verse2"].velocity_mult == 0.7 + assert sections["verse2"].vol_mult == 0.85 + + def test_chorus2_same_as_chorus(self): + sections = self._get_sections() + assert sections["chorus2"].velocity_mult == 1.0 + assert sections["chorus2"].vol_mult == 1.0 + + def test_bridge_has_low_multipliers(self): + sections = self._get_sections() + assert sections["bridge"].velocity_mult == 0.6 + assert sections["bridge"].vol_mult == 0.75 + + def test_final_has_full_multipliers(self): + sections = self._get_sections() + assert sections["final"].velocity_mult == 1.0 + assert sections["final"].vol_mult == 1.0 + + def test_outro_has_lowest_multipliers(self): + sections = self._get_sections() + assert sections["outro"].velocity_mult == 0.4 + assert sections["outro"].vol_mult == 0.60 + + def test_all_sections_have_multipliers(self): + """Every section name in SECTIONS has a corresponding entry in multipliers.""" + sections = self._get_sections() + from scripts.compose import SECTIONS + expected_names = {name for name, _, _, _ in SECTIONS} + assert set(sections.keys()) == expected_names + + +class TestSectionActiveHelper: + """Tests for _section_active() centralized activity helper.""" + + @staticmethod + def _get_activity(): + from scripts.compose import TRACK_ACTIVITY + return TRACK_ACTIVITY + + def test_intro_drumloop_active(self): + from scripts.compose import _section_active + assert _section_active("intro", "drumloop", self._get_activity()) is True + + def test_intro_bass_inactive(self): + from scripts.compose import _section_active + assert _section_active("intro", "bass", self._get_activity()) is False + + def test_intro_chords_inactive(self): + from scripts.compose import _section_active + assert _section_active("intro", "chords", self._get_activity()) is False + + def test_intro_lead_inactive(self): + from scripts.compose import _section_active + assert _section_active("intro", "lead", self._get_activity()) is False + + def test_chorus_all_active(self): + from scripts.compose import _section_active + activity = self._get_activity() + for role in ("drumloop", "perc", "bass", "chords", "lead", "clap", "pad"): + assert _section_active("chorus", role, activity) is True, f"chorus.{role} should be active" + + def test_verse_only_drumloop_bass_chords_active(self): + from scripts.compose import _section_active + activity = self._get_activity() + assert _section_active("verse", "drumloop", activity) is True + assert _section_active("verse", "bass", activity) is True + assert _section_active("verse", "chords", activity) is True + assert _section_active("verse", "lead", activity) is False + assert _section_active("verse", "perc", activity) is False + assert _section_active("verse", "clap", activity) is False + + def test_pre_chorus_section_name(self): + """The section is named pre-chorus, not build.""" + from scripts.compose import _section_active + activity = self._get_activity() + assert "pre-chorus" in activity, "pre-chorus must be a key in TRACK_ACTIVITY" + assert "build" not in activity, "build must NOT be a key in TRACK_ACTIVITY" + assert _section_active("pre-chorus", "bass", activity) is True + + def test_unknown_section_returns_false(self): + from scripts.compose import _section_active + assert _section_active("xyz", "bass", self._get_activity()) is False + + def test_unknown_role_returns_false(self): + from scripts.compose import _section_active + assert _section_active("chorus", "banjo", self._get_activity()) is False + + def test_outro_has_drumloop_and_pad(self): + from scripts.compose import _section_active + activity = self._get_activity() + assert _section_active("outro", "drumloop", activity) is True + assert _section_active("outro", "pad", activity) is True + assert _section_active("outro", "bass", activity) is False + + def test_bridge_has_drumloop_pad_lead(self): + from scripts.compose import _section_active + activity = self._get_activity() + assert _section_active("bridge", "drumloop", activity) is True + assert _section_active("bridge", "pad", activity) is True + assert _section_active("bridge", "lead", activity) is True + assert _section_active("bridge", "bass", activity) is False + assert _section_active("bridge", "chords", activity) is False + + def test_final_has_perc_bass_chords_lead_clap_pad(self): + from scripts.compose import _section_active + activity = self._get_activity() + assert _section_active("final", "drumloop", activity) is True + assert _section_active("final", "perc", activity) is True + assert _section_active("final", "bass", activity) is True + assert _section_active("final", "chords", activity) is True + assert _section_active("final", "lead", activity) is True + assert _section_active("final", "clap", activity) is True + assert _section_active("final", "pad", activity) is True + + class TestPluginRegistry: def test_plugins_in_registry(self): from src.reaper_builder import PLUGIN_REGISTRY diff --git a/tests/test_transitions_fx.py b/tests/test_transitions_fx.py new file mode 100644 index 0000000..9e2d0a9 --- /dev/null +++ b/tests/test_transitions_fx.py @@ -0,0 +1,476 @@ +"""Tests for transitions-fx — build_fx_track() and FX_TRANSITIONS map. + +Strict TDD: tests written BEFORE implementation (per TDD cycle). +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +import pytest + + +# ============================================================================ +# Phase 1: RED — FX_TRANSITIONS dict + FX_ROLE constant +# ============================================================================ + +class TestFxTransitionsDict: + """1.1 FX_TRANSITIONS dict: 7 boundaries → 8 clips.""" + + def test_fx_transitions_has_correct_boundary_count(self): + """FX_TRANSITIONS covers 7 section boundaries (indices 2-8).""" + from scripts.compose import FX_TRANSITIONS + assert isinstance(FX_TRANSITIONS, dict), "FX_TRANSITIONS must be a dict" + # 7 boundaries: indices 2,3,4,5,6,7,8 (NOT 0,1 — intro/verse have no FX) + expected_boundaries = {2, 3, 4, 5, 6, 7, 8} + assert set(FX_TRANSITIONS.keys()) == expected_boundaries, ( + f"Expected boundaries {sorted(expected_boundaries)}, " + f"got {sorted(FX_TRANSITIONS.keys())}" + ) + + def test_fx_transitions_total_clips_8(self): + """FX_TRANSITIONS defines exactly 8 clips (one boundary has riser+impact).""" + from scripts.compose import FX_TRANSITIONS + total = 0 + for vals in FX_TRANSITIONS.values(): + if isinstance(vals, list): + total += len(vals) + else: + total += 1 + assert total == 8, f"Expected 8 total clips, got {total}" + + def test_fx_transitions_boundary_2_sweep(self): + """verse→pre-chorus: sweep at position 46, length 2, fade_in 0.3.""" + from scripts.compose import FX_TRANSITIONS + entry = FX_TRANSITIONS[2] + # Boundary 2 has a single tuple (not nested list for single-clip boundaries) + assert isinstance(entry, tuple), f"Boundary 2 should be a single tuple, got {type(entry)}" + fx_type, offset, length, fade_in, fade_out = entry + assert fx_type == "sweep" + assert offset == -2, f"Expected offset -2 (boundary beat 48 - 2 = 46), got {offset}" + assert length == 2 + assert fade_in == 0.3 + assert fade_out == 0.0 + + def test_fx_transitions_boundary_3_has_riser_and_impact(self): + """pre-chorus→chorus: list of 2 tuples (riser + impact).""" + from scripts.compose import FX_TRANSITIONS + entries = FX_TRANSITIONS[3] + assert isinstance(entries, list), f"Boundary 3 must be a list for riser+impact, got {type(entries)}" + assert len(entries) == 2, f"Boundary 3 must have 2 entries, got {len(entries)}" + + # Riser + fx_type, offset, length, fade_in, fade_out = entries[0] + assert fx_type == "riser" + assert offset == -4, f"Expected offset -4 (boundary beat 64 - 4 = 60), got {offset}" + assert length == 4 + assert fade_in == 1.5 + assert fade_out == 0.0 + + # Impact + fx_type, offset, length, fade_in, fade_out = entries[1] + assert fx_type == "impact" + assert offset == 0, f"Expected offset 0 (boundary beat 64 + 0 = 64), got {offset}" + assert length == 2 + assert fade_in == 0.0 + assert fade_out == 0.3 + + def test_fx_transitions_boundary_4_transition(self): + """chorus→verse2: transition at position 94, length 2, fades 0.2/0.2.""" + from scripts.compose import FX_TRANSITIONS + entry = FX_TRANSITIONS[4] + fx_type, offset, length, fade_in, fade_out = entry + assert fx_type == "transition" + assert offset == -2, f"Expected offset -2 (boundary beat 96 - 2 = 94), got {offset}" + assert length == 2 + assert fade_in == 0.2 + assert fade_out == 0.2 + + def test_fx_transitions_boundary_5_riser(self): + """verse2→chorus2: riser at position 124, length 4, fade_in 1.0.""" + from scripts.compose import FX_TRANSITIONS + entry = FX_TRANSITIONS[5] + fx_type, offset, length, fade_in, fade_out = entry + assert fx_type == "riser" + assert offset == -4, f"Expected offset -4 (boundary beat 128 - 4 = 124), got {offset}" + assert length == 4 + assert fade_in == 1.0 + assert fade_out == 0.0 + + def test_fx_transitions_boundary_6_sweep(self): + """chorus2→bridge: sweep at position 158, length 2, fades 0.2/0.2.""" + from scripts.compose import FX_TRANSITIONS + entry = FX_TRANSITIONS[6] + fx_type, offset, length, fade_in, fade_out = entry + assert fx_type == "sweep" + assert offset == -2, f"Expected offset -2 (boundary beat 160 - 2 = 158), got {offset}" + assert length == 2 + assert fade_in == 0.2 + assert fade_out == 0.2 + + def test_fx_transitions_boundary_7_riser(self): + """bridge→final: riser at position 172, length 4, fade_in 1.0.""" + from scripts.compose import FX_TRANSITIONS + entry = FX_TRANSITIONS[7] + fx_type, offset, length, fade_in, fade_out = entry + assert fx_type == "riser" + assert offset == -4, f"Expected offset -4 (boundary beat 176 - 4 = 172), got {offset}" + assert length == 4 + assert fade_in == 1.0 + assert fade_out == 0.0 + + def test_fx_transitions_boundary_8_sweep(self): + """final→outro: sweep at position 206, length 2, fades 0.3/0.5.""" + from scripts.compose import FX_TRANSITIONS + entry = FX_TRANSITIONS[8] + fx_type, offset, length, fade_in, fade_out = entry + assert fx_type == "sweep" + assert offset == -2, f"Expected offset -2 (boundary beat 208 - 2 = 206), got {offset}" + assert length == 2 + assert fade_in == 0.3 + assert fade_out == 0.5 + + +class TestFxRoleConstant: + """1.2 FX_ROLE = 'fx' constant referencing ATONAL_ROLES.""" + + def test_fx_role_equals_fx(self): + """FX_ROLE is the string 'fx'.""" + from scripts.compose import FX_ROLE + assert FX_ROLE == "fx" + + def test_fx_role_in_atonal_roles(self): + """FX_ROLE value is in ATONAL_ROLES (skip key scoring).""" + from scripts.compose import FX_ROLE + from src.selector import ATONAL_ROLES + assert FX_ROLE in ATONAL_ROLES, "fx must be in ATONAL_ROLES for neutral key scoring" + + +# ============================================================================ +# Phase 2: RED — build_fx_track() function +# ============================================================================ + +def _make_fx_selector(samples=None): + """Build a mock SampleSelector that returns FX samples via select_one.""" + if samples is None: + samples = [ + {"original_path": f"fx_sample_{i}.wav", "original_name": f"FX {i}"} + for i in range(5) + ] + mock = MagicMock() + mock.select_one.return_value = samples[0] if samples else None + return mock + + +def _make_sections_and_offsets(): + """Return sections and offsets matching production SECTIONS layout.""" + from scripts.compose import SECTIONS + from src.core.schema import SectionDef + + sections = [] + for name, bars, energy, _has_lead in SECTIONS: + sections.append(SectionDef(name=name, bars=bars, energy=energy)) + + offsets = [] + off = 0.0 + for sec in sections: + offsets.append(off) + off += sec.bars + return sections, offsets + + +class TestBuildFxTrack: + """4.1-4.3 Unit tests for build_fx_track().""" + + def test_returns_trackdef_with_8_clips(self): + """build_fx_track produces a TrackDef with exactly 8 clips.""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + assert track.name == "Transition FX" + assert len(track.clips) == 8, f"Expected 8 clips, got {len(track.clips)}" + + def test_clip_positions_match_design(self): + """Clip positions match the design boundary map values.""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + + positions = sorted(c.position for c in track.clips) + expected = [46.0, 60.0, 64.0, 94.0, 124.0, 158.0, 172.0, 206.0] + assert positions == expected, ( + f"Expected positions {expected}, got {positions}" + ) + + def test_all_clips_have_audio_path(self): + """All 8 clips have audio_path set (not None).""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + + for i, clip in enumerate(track.clips): + assert clip.audio_path is not None, ( + f"Clip {i} (pos={clip.position}) has None audio_path" + ) + assert isinstance(clip.audio_path, str), ( + f"Clip {i} audio_path must be a string" + ) + assert len(clip.audio_path) > 0, ( + f"Clip {i} audio_path must not be empty" + ) + + def test_fade_values_match_design(self): + """Fade in/out values match the design table for each clip.""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + + # Build expected map: position → (fade_in, fade_out) + expected_fades = { + 46.0: (0.3, 0.0), # sweep + 60.0: (1.5, 0.0), # riser + 64.0: (0.0, 0.3), # impact + 94.0: (0.2, 0.2), # transition + 124.0: (1.0, 0.0), # riser + 158.0: (0.2, 0.2), # sweep + 172.0: (1.0, 0.0), # riser + 206.0: (0.3, 0.5), # sweep + } + + for clip in track.clips: + exp_fi, exp_fo = expected_fades.get(clip.position, (None, None)) + assert exp_fi is not None, f"Unexpected clip position: {clip.position}" + assert clip.fade_in == pytest.approx(exp_fi), ( + f"Clip at {clip.position}: fade_in={clip.fade_in}, expected {exp_fi}" + ) + assert clip.fade_out == pytest.approx(exp_fo), ( + f"Clip at {clip.position}: fade_out={clip.fade_out}, expected {exp_fo}" + ) + + def test_riser_has_fade_in_gt_zero(self): + """Spec requirement: riser clips have fade_in > 0.""" + from scripts.compose import build_fx_track, build_section_structure + from scripts.compose import FX_TRANSITIONS + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + + # Find which entries are risers + riser_positions = set() + for bound_idx, entries in FX_TRANSITIONS.items(): + items = entries if isinstance(entries, list) else [entries] + for fx_type, offset, length, fi, fo in items: + if fx_type == "riser": + boundary_beat = offsets[bound_idx] * 4.0 + riser_positions.add(boundary_beat + offset) + + for clip in track.clips: + if clip.position in riser_positions: + assert clip.fade_in > 0, ( + f"Riser at {clip.position} must have fade_in > 0" + ) + + def test_impact_has_fade_out_gt_zero(self): + """Spec requirement: impact clips have fade_out > 0.""" + from scripts.compose import build_fx_track, build_section_structure + from scripts.compose import FX_TRANSITIONS + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + + # Find which entries are impacts + impact_positions = set() + for bound_idx, entries in FX_TRANSITIONS.items(): + items = entries if isinstance(entries, list) else [entries] + for fx_type, offset, length, fi, fo in items: + if fx_type == "impact": + boundary_beat = offsets[bound_idx] * 4.0 + impact_positions.add(boundary_beat + offset) + + for clip in track.clips: + if clip.position in impact_positions: + assert clip.fade_out > 0, ( + f"Impact at {clip.position} must have fade_out > 0" + ) + + def test_track_volume_is_0_72(self): + """Spec requirement: FX track volume = 0.72.""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + assert track.volume == 0.72, f"Expected volume 0.72, got {track.volume}" + + def test_track_has_send_level(self): + """Spec requirement: FX track sends to Reverb (0.08) and Delay (0.05).""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + assert track.send_level == {0: 0.08, 1: 0.05}, ( + f"Expected send_level {{0: 0.08, 1: 0.05}}, got {track.send_level}" + ) + + def test_deterministic_with_same_seed(self): + """Same seed produces identical output (deterministic via select_one).""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector1 = _make_fx_selector() + selector2 = _make_fx_selector() + track1 = build_fx_track(sections, offsets, selector1, seed=42) + track2 = build_fx_track(sections, offsets, selector2, seed=42) + paths1 = [c.audio_path for c in track1.clips] + paths2 = [c.audio_path for c in track2.clips] + assert paths1 == paths2, "Same seed must produce same sample paths" + + def test_different_seed_may_produce_different(self): + """Different seeds use different calls to select_one (variation).""" + from scripts.compose import build_fx_track, build_section_structure + sections, offsets = build_section_structure() + selector = _make_fx_selector() + track = build_fx_track(sections, offsets, selector, seed=0) + # Verify that select_one was called with the correct role + all_calls = selector.select_one.call_args_list + for call in all_calls: + assert call[1]["role"] == "fx", ( + f"select_one must be called with role='fx', got {call}" + ) + + +# ============================================================================ +# Phase 3: RED — Integration (build_fx_track in main) +# ============================================================================ + +class TestFxTrackIntegration: + """3.1-3.2 Integration: build_fx_track called in main(), send wiring works.""" + + def test_transition_fx_track_in_main_output(self, tmp_path): + """main() produces RPP output containing 'Transition FX' track.""" + output = _mock_main_fx(tmp_path) + assert output.exists(), f"Expected {output} to exist" + content = output.read_text(encoding="utf-8") + assert "Transition FX" in content, ( + "Expected 'Transition FX' track in RPP output" + ) + + def test_fx_track_has_audio_source(self, tmp_path): + """FX track clips produce SOURCE WAVE entries.""" + output = _mock_main_fx(tmp_path) + content = output.read_text(encoding="utf-8") + # Count SOURCE WAVE entries — should include FX clips + wave_count = content.count("SOURCE WAVE") + assert wave_count > 2, f"Expected multiple WAVE sources, got {wave_count}" + + def test_fx_track_has_aux_sends(self, tmp_path): + """FX track has AUXRECV for reverb/delay sends.""" + output = _mock_main_fx(tmp_path) + content = output.read_text(encoding="utf-8") + assert "AUXRECV" in content, "Expected send routing for FX track" + + def test_fx_track_after_clap_before_pad(self, tmp_path): + """Track ordering: Clap → FX → Pad.""" + output = _mock_main_fx(tmp_path) + content = output.read_text(encoding="utf-8") + + # RPP format: