feat: professional reggaeton production engine — 7 SDD changes, 302 tests

- section-energy: track activity matrix + volume/velocity multipliers per section
- smart-chords: ChordEngine with voice leading, inversions, 4 emotion modes
- hook-melody: melody engine with hook/stabs/smooth styles, call-and-response
- mix-calibration: Calibrator module (LUFS volumes, HPF/LPF, stereo, sends, master)
- transitions-fx: FX track with risers/impacts/sweeps at section boundaries
- sidechain: MIDI CC11 bass ducking on kick hits via DrumLoopAnalyzer
- presets-pack: role-aware plugin presets (Serum/Decapitator/Omnisphere per role)

Full SDD pipeline (propose→spec→design→tasks→apply→verify) for all 7 changes.
302/302 tests passing.
This commit is contained in:
renato97
2026-05-03 23:54:29 -03:00
parent 48bc271afc
commit 014e636889
51 changed files with 11394 additions and 113 deletions

50
.gga Normal file
View File

@@ -0,0 +1,50 @@
# Gentleman Guardian Angel Configuration
# https://github.com/your-org/gga
# AI Provider (required)
# Options: claude, gemini, codex, opencode, ollama:<model>, lmstudio[:model], github:<model>
# 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"

View File

@@ -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, # 28 bars
seed: int = 42,
) -> list[MidiNote]:
"""Generate a 24 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.

View File

@@ -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**: 24 bar motif repeated 24x 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, 48 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 24 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

View File

@@ -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 24 bar repeating motif using scale-aware note selection. Three styles:
- **hook**: Arch contour (ascend then descend), chord tones on beats 0, 2, 4..., 48 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 (28) 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 412 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

View File

@@ -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, 48 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

View File

@@ -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.

View File

@@ -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 — existing compose pipeline is unchanged; calibration is additive -->
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)

View File

@@ -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.01.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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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`

View File

@@ -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)

View File

@@ -0,0 +1,82 @@
# Design: 808 Bass Sidechain Ducking
## Technical Approach
Extend `ClipDef` with `midi_cc: list[CCEvent]`, inject kick positions from `DrumLoopAnalyzer` into `build_bass_track()`, and modify `_build_midi_source()` to emit CC E-lines interleaved with notes. Pure MIDI — zero plugin or REAPER-specific features required.
## Architecture Decisions
| Decision | Choice | Rejected | Rationale |
|----------|--------|----------|-----------|
| CC representation | `dataclass CCEvent(controller, time, value)` | Dict/reuse MidiNote | Controller field orthogonal to pitch; typed dataclass catches errors at import time |
| CC in _build_midi_source | Sort `notes+cc` by time, single pass | Separate CC loop after notes | Single sorted pass guarantees correct delta-encoding; avoids cursor reset bugs |
| Kick cache lifetime | Module-level `dict[str, list[float]]` in compose.py | per-function lru_cache | Drumloop reused across sections; WAV path is natural stable key |
| Duck shape constants | `_CC11_DIP=50, _CC11_HOLD=0.02, _CC11_RELEASE=0.18` | Configuration file | 3 constants — config file is overkill; easy to change in-code |
| DrumLoopAnalyzer integration | Call `analyze()` once per unique WAV path | Per-section analysis | ~1s per analysis; caching avoids N×1s for N sections |
## Data Flow
```
drumloop WAV
→ DrumLoopAnalyzer.analyze() → transient_positions("kick")
→ filter confidence ≥ 0.6 → convert seconds→beats via bpm
→ _get_kick_cache() returns list[float]
→ build_bass_track(sections, offsets, key_root, key_minor, kick_cache)
→ per section: filter kicks within [clip_start, clip_end] beats
→ per kick in range: CCEvent(11, kick_t, 50), CCEvent(11, kick_t+0.02, 50), CCEvent(11, kick_t+0.18, 127)
→ ClipDef(..., midi_cc=[...])
→ RPPBuilder._build_midi_source()
→ merge notes+cc, sort by time → emit E-lines delta-encoded
```
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `src/core/schema.py` | Modify | Add `CCEvent` dataclass; add `midi_cc: list[CCEvent] = field(default_factory=list)` to `ClipDef` |
| `scripts/compose.py` | Modify | Add `_KICK_CONFIDENCE_THRESHOLD`, `_CC11_*` constants; add `_get_kick_cache()` function; modify `build_bass_track()` signature and CC generation; update `main()` to build kick cache |
| `src/reaper_builder/__init__.py` | Modify | Merge `clip.midi_notes + clip.midi_cc` sorted by time in `_build_midi_source()`; emit `E delta B0 0B {value:02x}` for CC events |
## Interfaces / Contracts
```python
# src/core/schema.py — new dataclass
@dataclass
class CCEvent:
controller: int # 11 = Expression (CC11)
time: float # beats from clip start
value: int # 0127
# 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.

View File

@@ -0,0 +1,101 @@
# Proposal: 808 Bass Sidechain Ducking
## Intent
808 bass and kick drum overlap in low frequencies with zero separation. Professional reggaeton uses sidechain-style ducking — bass dips when kick hits — creating the "pumping" feel and preventing low-frequency mud. Currently `build_bass_track()` generates static-velocity MIDI notes with no awareness of the drumloop's kick pattern.
## Scope
### In Scope
- Pre-analyze drumloop WAV files to extract kick transient positions via `DrumLoopAnalyzer`
- Cache kick beat-positions per drumloop path (same file reused across sections)
- Generate MIDI CC11 (Expression) events on bass clips at kick hit positions
- Duck shape: instantaneous drop to CC11≈50, 80ms release ramp to CC11=127
- `ClipDef` schema extended with `midi_cc: list[CCEvent]` field
- `RPPBuilder._build_midi_source()` emits CC E-lines interleaved with Note events
### Out of Scope
- Track-level volume automation envelopes (`VOLENV2`) — complex binary encoding, deferred
- ReaComp-sidechain routing via ReaScript — Phase 2 enhancement only
- DrumLoopAnalyzer integration at composition time (not pre-cached) — deferred to Phase 2
- Ducking for non-bass tracks (chords, lead, pad)
- User-configurable duck depth/shape — constants only
## Capabilities
### New Capabilities
- `midi-cc-events`: MIDI CC event emission in `.rpp` source — CC11 Expression events interleaved with notes in E-line stream
- `kick-detection-cache`: `DrumLoopAnalyzer` tied into composition pipeline; kick positions cached per drumloop WAV path
### Modified Capabilities
- `bass-generation`: `build_bass_track()` accepts kick position data and generates per-note velocity ducking OR CC11 events synchronized to kick hits
- `rpp-clip-encoding`: `_build_midi_source()` emits `E B0 0B xx` lines alongside Note On/Off
## Approach
**Principle**: MIDI CC11 (Expression) is the simplest `.rpp`-native sidechain. No REAPER-specific features, no binary envelope encoding, no ReaScript bridge. Pure MIDI standard — works with any synth (Serum 2 confirmed).
**Data flow**:
```
Drumloop WAV
→ DrumLoopAnalyzer.analyze() → transient_positions("kick")
→ beat-positions cache (dict[str, list[float]])
→ build_bass_track(sections, offsets, key_root, key_minor, kick_cache={})
→ generates CCEvent objects {controller=11, time, value}
→ ClipDef.midi_cc = [...]
→ RPPBuilder._build_midi_source() emits E-lines
```
**CC11 ducking shape per kick hit** (all times in beats relative to clip start):
| Offset from kick | CC11 Value | Description |
|-----------------|------------|-------------|
| kick_time | 50 | Instant dip (~-9dB) |
| kick_time + 0.02| 50 | Hold through transient |
| kick_time + 0.18| 127 | Release complete (80ms ≈ 0.16 beats) |
**Key decision — MIDI CC11 vs alternatives**:
| Option | Verdict | Why |
|--------|---------|-----|
| **A: MIDI CC11 (Expression)** | ✅ Chosen | `.rpp` MIDI source format supports `E B0 0B xx` lines. Serum 2, most synths respond. Trivial builder change. |
| B: Track volume envelope (VOLENV2) | ❌ Rejected | Binary/chunk encoding in `.rpp` — fragile, hard to debug, no benefit over CC11 for this use case. |
| C: ReaScript ReaComp sidechain | ⏸️ Deferred | Works only in Phase 2 with REAPER running. Use as future enhancement for non-MIDI audio basses. |
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `src/core/schema.py` | Modified | Add `CCEvent` dataclass (`controller`, `time`, `value`); add `midi_cc: list[CCEvent]` to `ClipDef` |
| `scripts/compose.py` | Modified | Add `_get_kick_cache()`, pass to `build_bass_track()`, generate CC11 events in bass clips |
| `src/reaper_builder/__init__.py` | Modified | `_build_midi_source()` interleaves CC events into E-line stream |
| `src/composer/drum_analyzer.py` | Unchanged | Already exports `transient_positions("kick")` — zero changes needed |
| `tests/test_compose_integration.py` | Modified | Verify CC events present in bass clips, correct CC11 values at kick positions |
| `tests/test_reaper_builder.py` | Modified | Verify `_build_midi_source()` emits `B0 0B` E-lines |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Synth doesn't respond to CC11 | Low | Serum 2, Omnisphere, Diva all support it. Add `_CC11_VOLUME_MIN` constant for easy disable (set to 127 = no ducking). |
| DrumloopAnalyzer misclassifies kick transients | Med | Only use transients with `confidence > 0.6`; add `KICK_CONFIDENCE_THRESHOLD = 0.6` constant. |
| CC events overlap MIDI notes in E-line stream | Low | Sort all events (notes + CC) by absolute time; REAPER E-lines are monotonic delta-encoded. |
## Rollback Plan
Delete `midi_cc` from `ClipDef` and revert builder to skip CC events. Remove `_get_kick_cache()` from compose.py. No schema migrations needed — `midi_cc` defaults to empty list (zero behavioral change). One-commit revert.
## Dependencies
- `librosa` (already a project dependency via `drum_analyzer.py`)
- `DrumLoopAnalyzer` (already implemented and tested)
- No new packages, no external APIs.
## Success Criteria
- [ ] Bass MIDI clips contain CC11 (Expression) E-lines at kick hit positions
- [ ] CC11 value drops to ~50 at kick onset, recovers to 127 within 0.18 beats
- [ ] DrumLoopAnalyzer correctly identifies kick transients in all 5 drumloop variants
- [ ] Kick position cache avoids re-analyzing same WAV across sections
- [ ] 110 existing tests pass unchanged
- [ ] `.rpp` output opens in REAPER without errors; bass audibly ducks when kick hits
- [ ] `validate_rpp_output()` reports no regressions

View File

@@ -0,0 +1,76 @@
# Delta Spec: 808 Bass Sidechain Ducking
## ADDED Requirements
### Requirement: MIDI CC11 Event Data Model
The schema MUST support an `CCEvent` dataclass with controller, time, and value fields, and `ClipDef` MUST accept an optional `midi_cc: list[CCEvent]` field defaulting to empty list.
#### Scenario: CCEvent round-trips correctly
- GIVEN `CCEvent(controller=11, time=0.5, value=50)`
- WHEN serialized/deserialized via dataclass
- THEN all fields preserved exactly
#### Scenario: ClipDef with midi_cc
- GIVEN a `ClipDef` with `midi_cc=[CCEvent(11, 0.0, 50), CCEvent(11, 0.18, 127)]`
- WHEN clip is processed by builder
- THEN builder sees `midi_cc` field and can iterate it
### Requirement: Kick Position Cache
A kick-cache dict `{drumloop_wav_path: list[beat_positions]}` SHALL be computed once per session, keyed by WAV path. `DrumLoopAnalyzer.transient_positions("kick")` MUST be the source, filtered by `confidence >= KICK_CONFIDENCE_THRESHOLD` (default 0.6).
#### Scenario: Cache hit
- GIVEN drumloop WAV already analyzed in same session
- WHEN `build_bass_track()` requests kick positions for that path
- THEN cached positions returned without re-analyzing WAV
#### Scenario: Cache miss
- GIVEN drumloop WAV not yet cached
- WHEN kick positions requested
- THEN `DrumLoopAnalyzer.analyze()` runs, positions cached by path key
### Requirement: CC11 Ducking on Kick Hits
For each kick transient position in the bass clip's time span, the system MUST emit CC11 events forming a ducking envelope: instantaneous drop to value 50 at kick time, hold at 50 for 0.02 beats, ramp to 127 by 0.18 beats after kick.
#### Scenario: Single kick duck
- GIVEN kick at beat 1.0 within a 4-beat bass clip
- WHEN CC events generated
- THEN emits `CCEvent(11, 1.0, 50)`, `CCEvent(11, 1.02, 50)`, `CCEvent(11, 1.18, 127)`
#### Scenario: No kicks in clip
- GIVEN drumloop with no kick transients in clip time range
- THEN `midi_cc` is empty list — no CC events emitted
## MODIFIED Requirements
### Requirement: RPPBuilder MIDI Source Encoding
`_build_midi_source()` MUST emit MIDI CC events as `E B0 0B xx` lines interleaved with note events, all sorted by absolute start time. Delta-encoding MUST continue for all E-lines.
#### Scenario: CC events interleaved with notes
- GIVEN clip with `midi_notes=[Note(60, 0.5, 1.0)]` and `midi_cc=[CCEvent(11, 0.0, 50)]`
- WHEN `_build_midi_source()` called
- THEN E-lines emitted in time order: CC at 0.0, Note at 0.5
- AND CC line reads `E 0 B0 0B 32` (delta=0, CC11, value=50=0x32)
#### Scenario: Delta sequencing across note+CC
- GIVEN CC at 0.0, note at 0.5 beats
- WHEN building E-lines
- THEN CC delta = 0×960 = 0; note delta = 0.5×960 - 0 = 480
- AND cursor reset correctly after CC event ticks
### Requirement: Bass Track Generation
`build_bass_track()` SHALL accept an optional `kick_cache: dict[str, list[float]]` parameter. When kick data is present for the drumloop used in each section, `midi_cc` events SHALL be generated and added to the bass `ClipDef`.
#### Scenario: Bass clip with ducking CC
- GIVEN kick cache has `[1.0, 2.5]` for drumloop, section covers beats 0-16
- WHEN bass track built
- THEN bass clip at that section has `midi_cc` with 2×3 CC events (one envelope per kick in range)
- AND note generation unchanged from existing behavior
#### Scenario: No kick cache provided
- GIVEN `kick_cache` is `{}` or omitted
- THEN `midi_cc` is empty — zero behavioral change from current output

View File

@@ -0,0 +1,26 @@
# Tasks: 808 Bass Sidechain Ducking
## Phase 1: Schema — Foundation
- [x] 1.1 Add `CCEvent` dataclass (`controller: int`, `time: float`, `value: int`) to `src/core/schema.py`
- [x] 1.2 Add `midi_cc: list[CCEvent] = field(default_factory=list)` to `ClipDef` in `src/core/schema.py`
- [x] 1.3 Update `asdict` if used; verify `song.validate()` passes with empty `midi_cc`
## Phase 2: Kick Cache + CC Generation
- [x] 2.1 Add constants `_KICK_CONFIDENCE_THRESHOLD=0.6`, `_CC11_DIP=50`, `_CC11_HOLD=0.02`, `_CC11_RELEASE=0.18` to `scripts/compose.py`
- [x] 2.2 Add `_get_kick_cache(drumloop_paths: list[str], bpm: float) -> dict[str, list[float]]` to `scripts/compose.py`
- [x] 2.3 Modify `build_bass_track()` to accept `kick_cache: dict[str, list[float]]` parameter; generate CC events for kicks in range
- [x] 2.4 Update `main()` to build kick cache from drumloop paths and pass to `build_bass_track()`
## Phase 3: Builder CC Emission
- [x] 3.1 Modify `_build_midi_source()` in `src/reaper_builder/__init__.py` to merge `notes + cc` events and emit `E B0 0B {value:02x}` lines
- [x] 3.2 Verify delta cursor correctly advances across CC events (CC events contribute zero ticks)
## Phase 4: Testing
- [x] 4.1 Unit test `CCEvent` dataclass round-trip in `tests/test_schema.py`
- [x] 4.2 Unit test `_build_midi_source()` emits `B0 0B` lines for clips with `midi_cc`
- [x] 4.3 Integration test `build_bass_track()` populates `midi_cc` when kick cache present
- [x] 4.4 Regression: run existing 261 tests, verify all pass unchanged

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 (79) |
| 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" `<TRACK` block |
| Existing | 110 tests pass | `pytest` before/after regression |
## Open Questions
None — all dependencies exist today (`SampleSelector`, `ClipDef.fade_in/out`, `SECTIONS` structure).

View File

@@ -0,0 +1,77 @@
# Proposal: transitions-fx
## Intent
9 sections play back-to-back with zero transition — the song feels like disjointed loops. Add transition FX (risers, impacts, sweeps) at section boundaries to glue sections into a coherent arrangement.
## Scope
### In Scope
- Place transition FX clips (audio samples) on a dedicated "Transition FX" track at section boundaries
- Riser/wash FX: 24 beats before section changes (e.g., build → chorus drop)
- Impact/hit FX: on the downbeat of CHORUS, FINAL, VERSE2 entries
- Filter sweep simulation via fade-in/fade-out on adjacent clips
- Transition plan: which boundary gets which FX type + duration
- Reuse existing FX-role samples from library (impacts, risers, transition FX, wash)
### Out of Scope
- Synthesized FX generation (numpy waveform synthesis) — deferred to future
- MIDI CC filter automation in RPP (no CC support in builder today)
- Per-track volume automation curves
- Reverse cymbal (no suitable samples in library)
## Capabilities
### New Capabilities
- `transition-fx`: Placement of audio FX clips at section boundaries for arrangement glue
### Modified Capabilities
None — existing section/track structure unchanged.
## Approach
**Audio samples from library** — the library has 57 FX-role samples including:
- Impacts: `fx_C2_126_boomy` (2.5s, from `impact.wav`)
- Risers: `fx_C#5_123_aggressive` (30s), `fx_G3_143_boomy` (6.6s, "RISER 3")
- Transition FX: 4 "transicion fx" variants (1.01.7s)
- Wash/noise: `fx_G#6_136_aggressive` (3.3s)
- Short shots/gates: "CAMTAZO 12" (1.52.0s), "PUERTA" (0.2s)
Place audio clips on a new "FX" track at section boundaries:
- **Riser/wash**: starts 24 beats BEFORE boundary, ends on boundary downbeat
- **Impact**: starts on boundary downbeat, short duration (12 beats)
- Use existing `fade_in`/`fade_out` on ClipDef for filter-like sweeps
- Use SampleSelector with `role="fx"` to pick compatible samples
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `scripts/compose.py` | Modified | Add `build_fx_track()` — places FX clips between sections |
| `src/core/schema.py` | Unchanged | `ClipDef` already has `position`, `length`, `audio_path`, `fade_in`, `fade_out` |
| `src/reaper_builder/__init__.py` | Unchanged | Audio clip building already works |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| FX samples may not match project key | Low | FX role is ATONAL_ROLES — key scoring skipped by selector |
| Long riser samples exceed needed duration | Low | Clip length sets playback window; sample trimmed automatically |
| No suitable riser for specific boundary | Med | Fall back to fade_in on next section clip |
## Rollback Plan
Remove `build_fx_track()` call from `main()`. Existing tracks untouched.
## Dependencies
- `data/sample_index.json` (already exists)
- `SampleSelector` with `role="fx"` (already works)
## Success Criteria
- [ ] `python scripts/compose.py --bpm 99 --key Am` produces .rpp with FX clips at section boundaries
- [ ] At least one FX clip between: build→chorus, chorus→verse2, bridge→final, outro end
- [ ] FX clips have appropriate fade_in/fade_out curves
- [ ] 110 existing tests continue to pass
- [ ] Song renders without gaps — FX clips overlap/bridge sections

View File

@@ -0,0 +1,95 @@
# Transition FX Specification
## Purpose
Glue sections together by placing audio FX clips at arrangement boundaries using existing `role="fx"` library samples.
## Requirements
### Requirement: FX Track Existence
The system MUST create a dedicated "Transition FX" audio track with clips at 7 section boundaries.
#### Scenario: FX track present in arrangement
- GIVEN a 9-section song
- WHEN `compose.py` runs
- THEN a track named "Transition FX" exists with 7+ audio clips at boundary positions
### Requirement: Riser Before Climax
A riser/wash FX MUST start 24 beats before build→chorus and bridge→final boundaries, ending ON the boundary downbeat.
#### Scenario: Riser before chorus
- GIVEN build ends at beat 64 (bar 16)
- WHEN FX is built
- THEN a riser at position 60 (beat 60), length 4, `fade_in` ≥ 1.0s
#### Scenario: Riser before final
- GIVEN bridge ends at beat 176 (bar 44)
- WHEN FX is built
- THEN a riser at position 172, length 4, `fade_in` ≥ 1.0s
#### Scenario: Riser before chorus2
- GIVEN verse2 ends at beat 128 (bar 32)
- WHEN FX is built
- THEN a riser at position 124, length 4, `fade_in` ≥ 1.0s
### Requirement: Impact on Section Downbeat
An impact/stab FX MUST start on beat 1 of CHORUS (beat 64) and FINAL (beat 176).
#### Scenario: Impact on chorus beat 1
- GIVEN chorus starts at beat 64
- WHEN FX is built
- THEN an impact clip at position 64, length 12 beats, `fade_out` ≥ 0.2s
#### Scenario: Impact on final beat 1
- GIVEN final starts at beat 176
- WHEN FX is built
- THEN an impact clip at position 176, length 12 beats
### Requirement: Transition Sweeps Between Verses
Short transition FX MUST bridge chorus→verse2 (beat 96) and chorus2→bridge (beat 160).
#### Scenario: Sweep bridges chorus to verse2
- GIVEN chorus ends at beat 96
- WHEN FX is built
- THEN a transition clip at position 94, length 2 beats, `fade_in` and `fade_out` > 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)

View File

@@ -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

22
AGENTS.md Normal file
View File

@@ -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/<change-name>/

2488
output/analysis_test.rpp Normal file

File diff suppressed because it is too large Load Diff

2488
output/test_song_check.rpp Normal file

File diff suppressed because it is too large Load Diff

49
scripts/_match_samples.py Normal file
View File

@@ -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()

View File

@@ -23,11 +23,14 @@ _ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(_ROOT)) sys.path.insert(0, str(_ROOT))
from src.core.schema import ( from src.core.schema import (
SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent,
PluginDef, SectionDef, PluginDef, SectionDef,
) )
from src.selector import SampleSelector from src.selector import SampleSelector
from src.reaper_builder import RPPBuilder, PLUGIN_REGISTRY, PLUGIN_PRESETS 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 # Constants
@@ -46,15 +49,15 @@ ABLETON_DRUMLOOP_DIR = Path(
# Each section gets a drumloop variant: "seco", "filtrado", "empty", "seco" # Each section gets a drumloop variant: "seco", "filtrado", "empty", "seco"
# This cycles through the sections # This cycles through the sections
DRUMLOOP_ASSIGNMENTS = { DRUMLOOP_ASSIGNMENTS = {
"intro": "filtrado", # filtered intro "intro": "filtrado", # filtered intro
"verse": "seco", # dry verse "verse": "seco", # dry verse
"build": "filtrado", # building with filter "pre-chorus": "filtrado", # building with filter
"chorus": "seco", # full energy dry "chorus": "seco", # full energy dry
"break": "empty", # breakdown — no drumloop "verse2": "seco", # dry verse 2
"chorus2": "seco", # full energy dry "chorus2": "seco", # full energy dry
"bridge": "filtrado", # filtered bridge "bridge": "filtrado", # filtered bridge
"final": "seco", # full energy "final": "seco", # full energy
"outro": "filtrado", # filtered outro "outro": "filtrado", # filtered outro
} }
# Drumloop files for each variant # Drumloop files for each variant
@@ -100,7 +103,7 @@ BASS_PATTERN_8BARS = [
SECTIONS = [ SECTIONS = [
("intro", 4, 0.3, False), ("intro", 4, 0.3, False),
("verse", 8, 0.5, True), ("verse", 8, 0.5, True),
("build", 4, 0.7, False), ("pre-chorus", 4, 0.7, False),
("chorus", 8, 1.0, True), ("chorus", 8, 1.0, True),
("verse2", 8, 0.5, True), ("verse2", 8, 0.5, True),
("chorus2", 8, 1.0, True), ("chorus2", 8, 1.0, True),
@@ -132,12 +135,13 @@ FX_CHAINS = {
} }
SEND_LEVELS = { SEND_LEVELS = {
"bass": (0.05, 0.02), "bass": (0.05, 0.02),
"chords": (0.15, 0.08), "chords": (0.15, 0.08),
"lead": (0.10, 0.05), "lead": (0.10, 0.05),
"clap": (0.05, 0.02), "clap": (0.05, 0.02),
"pad": (0.25, 0.15), "pad": (0.25, 0.15),
"perc": (0.05, 0.02), "perc": (0.05, 0.02),
"transition_fx": (0.08, 0.05),
} }
VOLUME_LEVELS = { VOLUME_LEVELS = {
@@ -152,6 +156,56 @@ VOLUME_LEVELS = {
MASTER_VOLUME = 0.85 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 # 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] 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: if registry_key in PLUGIN_REGISTRY:
display, path, uid = PLUGIN_REGISTRY[registry_key] display, path, uid = PLUGIN_REGISTRY[registry_key]
preset = PLUGIN_PRESETS.get(registry_key) 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) return PluginDef(name=registry_key, path=path, index=index, preset_data=preset, role=role)
return PluginDef(name=registry_key, path=registry_key, index=index) 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(): 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 = [] offsets = []
off = 0.0 off = 0.0
for sec in sections: for sec in sections:
@@ -198,6 +260,45 @@ def build_section_structure():
return sections, offsets 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 # Track Builders
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -220,9 +321,12 @@ def build_drumloop_track(sections, offsets, seed: int = 0) -> TrackDef:
filtrado_idx = 0 filtrado_idx = 0
for section, sec_off in zip(sections, offsets): 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 # Determine variant
section_key = section.name variant = DRUMLOOP_ASSIGNMENTS.get(section.name, "seco")
variant = DRUMLOOP_ASSIGNMENTS.get(section_key, "seco")
if variant == "empty": if variant == "empty":
continue # no drumloop in this section 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", name=f"{section.name.capitalize()} Drumloop",
audio_path=path, audio_path=path,
loop=True, 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( return TrackDef(
name="Drumloop", name="Drumloop",
volume=VOLUME_LEVELS["drumloop"], volume=VOLUME_LEVELS["drumloop"],
@@ -272,8 +377,8 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef:
clips = [] clips = []
for i, (section, sec_off) in enumerate(zip(sections, offsets)): for i, (section, sec_off) in enumerate(zip(sections, offsets)):
# Perc in verse and chorus only, not intro/break/outro # Use centralized activity matrix instead of ad-hoc name check
if section.name in ("intro", "break", "bridge", "outro"): if not _section_active(section.name, "perc", TRACK_ACTIVITY):
continue continue
perc_name = perc_files[i % len(perc_files)] 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", name=f"{section.name.capitalize()} Perc",
audio_path=str(perc_path), audio_path=str(perc_path),
loop=True, 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( return TrackDef(
name="Perc", name="Perc",
volume=VOLUME_LEVELS["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: def build_bass_track(sections, offsets, key_root: str, key_minor: bool,
"""808 bass using PROVEN harmonic pattern from Ableton project.""" 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 root_midi = key_to_midi_root(key_root, 1) # Octave 1 for 808
# Transpose the Ableton pattern to match the project key # Transpose the Ableton pattern to match the project key
# Ableton pattern is in Am (root=33=A1), transpose to project key # Ableton pattern is in Am (root=33=A1), transpose to project key
transpose = root_midi - 33 # 33 is A1 from Ableton pattern transpose = root_midi - 33 # 33 is A1 from Ableton pattern
kick_cache = kick_cache or {}
clips = [] clips = []
for section, sec_off in zip(sections, offsets): for section, sec_off in zip(sections, offsets):
vm = section.energy if not _section_active(section.name, "bass", TRACK_ACTIVITY):
velocity = int(80 + 15 * vm) # 80-95 depending on energy continue
velocity = int(80 * section.velocity_mult)
notes = [] notes = []
bars = section.bars bars = section.bars
@@ -329,15 +442,36 @@ def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> Track
velocity=velocity, 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: if notes:
clips.append(ClipDef( clips.append(ClipDef(
position=sec_off * 4.0, position=sec_off * 4.0,
length=section.bars * 4.0, length=section.bars * 4.0,
name=f"{section.name.capitalize()} 808", name=f"{section.name.capitalize()} 808",
midi_notes=notes, 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( return TrackDef(
name="808 Bass", name="808 Bass",
volume=VOLUME_LEVELS["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: def build_chords_track(
"""Chords: i-VI-III-VII on downbeats, match key.""" sections, offsets, key_root: str, key_minor: bool,
root_midi = key_to_midi_root(key_root, 3) 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 = [] clips = []
for section, sec_off in zip(sections, offsets): for section, sec_off in zip(sections, offsets):
if section.name in ("intro", "break", "outro"): if section.name in ("intro", "break", "outro"):
continue # no chords in sparse sections continue # no chords in sparse sections
vm = section.energy vm = section.energy
voicings = engine.progression(
section.bars, emotion=emotion,
beats_per_chord=4, inversion=inversion,
)
notes = [] notes = []
for bar in range(section.bars): for bar in range(section.bars):
ci = bar % len(CHORD_PROGRESSION) chord_idx = bar % len(voicings)
interval, quality = CHORD_PROGRESSION[ci] voicing = voicings[chord_idx]
for pitch in build_chord(root_midi + interval, quality): for pitch in voicing:
notes.append(MidiNote( notes.append(MidiNote(
pitch=pitch, pitch=pitch,
start=bar * 4.0, start=bar * 4.0,
@@ -375,7 +519,7 @@ def build_chords_track(sections, offsets, key_root: str, key_minor: bool) -> Tra
midi_notes=notes, 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( return TrackDef(
name="Chords", name="Chords",
volume=VOLUME_LEVELS["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: 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.""" """Lead melody: hook-based call-response using melody_engine.
penta = get_pentatonic(key_root, key_minor, 4) + get_pentatonic(key_root, key_minor, 5)
rng = random.Random(seed) Replaces random pentatonic generation with deterministic motif engine
producing arch-contour hooks, chord-tone emphasis, and call-response phrasing.
"""
clips = [] clips = []
for section, sec_off in zip(sections, offsets): for section, sec_off in zip(sections, offsets):
# Lead only in chorus and final sections # Lead only in sections where the lead role is active
if section.name not in ("chorus", "chorus2", "final"): if not _section_active(section.name, "lead", TRACK_ACTIVITY):
continue continue
vm = section.energy # Build a hook motif for this section (4 bars), then expand to section length
density = 0.4 motif = build_motif(key_root, key_minor, "hook", bars=min(4, section.bars), seed=seed)
notes = [] 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): # Scale velocities to section energy
for sixteenth in range(16): for note in notes:
bp = bar * 4.0 + sixteenth * 0.25 note.velocity = int(note.velocity * section.velocity_mult)
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),
))
if notes: if notes:
clips.append(ClipDef( 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, length=section.bars * 4.0,
name=f"{section.name.capitalize()} Lead", name=f"{section.name.capitalize()} Lead",
midi_notes=notes, 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( return TrackDef(
name="Lead", name="Lead",
volume=VOLUME_LEVELS["lead"], volume=VOLUME_LEVELS["lead"],
@@ -440,7 +578,7 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef:
clips = [] clips = []
if clap_path: if clap_path:
for section, sec_off in zip(sections, offsets): 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 continue
for bar in range(section.bars): for bar in range(section.bars):
for cb in CLAP_POSITIONS: for cb in CLAP_POSITIONS:
@@ -449,9 +587,10 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef:
length=0.5, length=0.5,
name=f"{section.name.capitalize()} Clap", name=f"{section.name.capitalize()} Clap",
audio_path=clap_path, 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( return TrackDef(
name="Clap", name="Clap",
volume=VOLUME_LEVELS["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: def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackDef:
"""Pad: sustained root chord, only in chorus/build sections.""" """Pad: sustained root chord, only in chorus/build sections."""
root_midi = key_to_midi_root(key_root, 3) 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 = [] clips = []
for section, sec_off in zip(sections, offsets): for section, sec_off in zip(sections, offsets):
# Pad in build, chorus, bridge, final only # Pad only where the pad role is active
if section.name not in ("build", "chorus", "chorus2", "bridge", "final"): if not _section_active(section.name, "pad", TRACK_ACTIVITY):
continue continue
vm = section.energy velocity = int(55 * section.velocity_mult)
notes = [ 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 for p in chord
] ]
clips.append(ClipDef( 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, length=section.bars * 4.0,
name=f"{section.name.capitalize()} Pad", name=f"{section.name.capitalize()} Pad",
midi_notes=notes, 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( return TrackDef(
name="Pad", name="Pad",
volume=VOLUME_LEVELS["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("--key", default="Am", help="Key (default: Am)")
parser.add_argument("--output", default="output/song.rpp", help="Output path") 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("--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() args = parser.parse_args()
if args.seed is not None: if args.seed is not None:
@@ -552,14 +753,22 @@ def main() -> None:
total_beats = sum(s.bars for s in sections) * 4.0 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)") 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 # Build tracks
tracks = [ tracks = [
build_drumloop_track(sections, offsets, seed=args.seed or 0), build_drumloop_track(sections, offsets, seed=args.seed or 0),
build_perc_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_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_lead_track(sections, offsets, key_root, key_minor, seed=args.seed or 42),
build_clap_track(selector, sections, offsets), 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), 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]} track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]}
# Assemble # 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( song = SongDefinition(
meta=meta, meta=meta,
tracks=all_tracks, tracks=all_tracks,
@@ -585,6 +797,10 @@ def main() -> None:
master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"], 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() errors = song.validate()
if errors: if errors:
print("WARNING: validation errors:", file=sys.stderr) print("WARNING: validation errors:", file=sys.stderr)

185
src/calibrator/__init__.py Normal file
View File

@@ -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

63
src/calibrator/presets.py Normal file
View File

@@ -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.01.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.01.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),
}

217
src/composer/chords.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -207,12 +207,13 @@ def _make_plugin_template(
if entry: if entry:
display_name, filename, uid_guid = 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: else:
# Unresolved — use name/path as display name with empty GUID # 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}" display_name = f"VST3: {resolved_name}" if not is_vst2 else f"VST: {resolved_name}"
filename = resolved_path filename = resolved_path
uid_guid = "" uid_guid = ""
preset_data = None
return PluginTemplate( return PluginTemplate(
name=resolved_name, 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) or all(pl.strip() in ("0 0", "0", "") for pl in preset_lines)
) )
if is_fake_preset and registry_key: if is_fake_preset and registry_key:
registry_preset = PLUGIN_PRESETS.get(registry_key) registry_preset = PLUGIN_PRESETS.get((registry_key, ""))
if registry_preset: if registry_preset:
preset_lines = registry_preset preset_lines = registry_preset

View File

@@ -32,6 +32,22 @@ class SongMeta:
ppq: int = 960 # ticks per quarter note (REAPER default) ppq: int = 960 # ticks per quarter note (REAPER default)
time_sig_num: int = 4 # numerator e.g. 4 time_sig_num: int = 4 # numerator e.g. 4
time_sig_den: int = 4 # denominator 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: 0127
"""
controller: int
time: float
value: int
@dataclass @dataclass
@@ -121,9 +137,11 @@ class ClipDef:
name: str = "" name: str = ""
audio_path: str | None = None # for audio clips audio_path: str | None = None # for audio clips
midi_notes: list[MidiNote] = field(default_factory=list) # for MIDI 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 loop: bool = False
fade_in: float = 0.0 fade_in: float = 0.0
fade_out: float = 0.0 fade_out: float = 0.0
vol_mult: float = 1.0
@property @property
def is_midi(self) -> bool: def is_midi(self) -> bool:
@@ -150,6 +168,7 @@ class PluginDef:
index: int = 0 index: int = 0
params: dict[int, float] = field(default_factory=dict) params: dict[int, float] = field(default_factory=dict)
preset_data: list[str] | None = None preset_data: list[str] | None = None
role: str = "" # track role for role-aware preset lookup (e.g. "bass", "lead", "pad")
@dataclass @dataclass

View File

@@ -799,7 +799,8 @@ ALIAS_MAP: dict[str, str] = {
} }
# Auto-generated preset data from output/all_plugins_v2.rpp # 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": [ "Arcade": [
"AjncLu5e7f4AAAAAAgAAAAEAAAAAAAAAAgAAAAAAAAAy0wEAAQAAAP//AAA=", "AjncLu5e7f4AAAAAAgAAAAEAAAAAAAAAAgAAAAAAAAAy0wEAAQAAAP//AAA=",
"ItMBAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAB0wpGQkNoAAAAAkFSQ0QAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "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) # 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() 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 # RPPBuilder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1747,7 +1795,34 @@ class RPPBuilder:
and appends preset data lines as string children. and appends preset data lines as string children.
Handles both VST2 (.dll with <> GUID) and VST3 (.vst3 with {} GUID). 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 # Resolve alias if needed
resolved_name = ALIAS_MAP.get(plugin.name, plugin.name) resolved_name = ALIAS_MAP.get(plugin.name, plugin.name)
@@ -1763,24 +1838,10 @@ class RPPBuilder:
if entry: if entry:
display_name, filename, uid_guid = entry display_name, filename, uid_guid = entry
registry_presets = PLUGIN_PRESETS.get(resolved_name) preset_data = _resolve_preset(resolved_name, plugin.role, plugin.preset_data)
preset_data = registry_presets if registry_presets else plugin.preset_data
return _build_plugin_element(display_name, filename, uid_guid, preset_data) return _build_plugin_element(display_name, filename, uid_guid, preset_data)
# Built-in VST2 plugins (ReaEQ, ReaComp, etc.) — .dll format # Fallback built-in VST2 path (no params, no registry entry)
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",
}
dll_name = dll_map.get(plugin.name, plugin.path) dll_name = dll_map.get(plugin.name, plugin.path)
param_slots = ["0"] * 19 param_slots = ["0"] * 19
return Element("VST", [plugin.name, dll_name, "0", "", *param_slots]) return Element("VST", [plugin.name, dll_name, "0", "", *param_slots])
@@ -1803,27 +1864,58 @@ class RPPBuilder:
source = Element("SOURCE", ["WAVE"]) source = Element("SOURCE", ["WAVE"])
source.append(["FILE", clip.audio_path]) source.append(["FILE", clip.audio_path])
item.append(source) item.append(source)
if clip.vol_mult != 1.0:
item.append(["D_VOL", str(clip.vol_mult)])
elif clip.is_midi: elif clip.is_midi:
item.append(self._build_midi_source(clip)) item.append(self._build_midi_source(clip))
return item return item
def _build_midi_source(self, clip: ClipDef) -> Element: 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 = Element("SOURCE", ["MIDI"])
source.append(["HASDATA", "1", "960", "QN"]) source.append(["HASDATA", "1", "960", "QN"])
ppq = 960 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 cursor = 0.0
for note in sorted_notes: for evt_time, evt_kind, evt_obj in events:
start_ticks = int(note.start * ppq) if evt_kind == "note":
delta = start_ticks - cursor note = evt_obj
cursor = start_ticks 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}']) velocity = int(note.velocity * vol) if vol != 1.0 else note.velocity
off_delta = int(note.duration * ppq) velocity = max(1, min(127, velocity))
source.append(['E', str(off_delta), '80', f'{note.pitch:02x}', '00'])
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 return source

View File

@@ -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,
}

631
tests/test_calibrator.py Normal file
View File

@@ -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

312
tests/test_chords.py Normal file
View File

@@ -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"

View File

@@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parents[1])) sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest 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.reaper_builder import RPPBuilder
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
@@ -152,7 +152,7 @@ class TestDrumloopFirstTracks:
def test_all_tracks_created(self, tmp_path): def test_all_tracks_created(self, tmp_path):
output = _mock_main(tmp_path) output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8") 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" assert name in content, f"Expected track '{name}' in output"
def test_clap_on_dembow_beats(self, tmp_path): def test_clap_on_dembow_beats(self, tmp_path):
@@ -197,24 +197,48 @@ class TestDrumloopFirstTracks:
for s in starts: for s in starts:
assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat" assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat"
def test_melody_uses_pentatonic(self): def test_melody_uses_hook_structure(self):
from scripts.compose import build_melody_track """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.core.schema import SectionDef
from src.composer.melody_engine import _resolve_chord_tones
sections = [SectionDef(name="chorus", bars=4, energy=1.0)] sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
offsets = [0.0] offsets = [0.0]
track = build_melody_track(sections, offsets, "A", True, seed=42) track = build_melody_track(sections, offsets, "A", True, seed=42)
assert len(track.clips) > 0, "Melody should have clips" 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" 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): def test_master_chain_present(self, tmp_path):
output = _mock_main(tmp_path) output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8") content = output.read_text(encoding="utf-8")
assert "Pro-Q" in content, "Expected Pro-Q 3 in master chain" # After calibration, master chain uses Ozone 12 triplet (with spaces in RPP)
assert "Pro-C" in content, "Expected Pro-C 2 in master chain" assert "Ozone 12" in content or "Pro-Q" in content, (
assert "Pro-L" in content, "Expected Pro-L 2 in master chain" "Expected Ozone 12 or Pro-Q in master chain"
)
def test_sends_wired(self, tmp_path): def test_sends_wired(self, tmp_path):
output = _mock_main(tmp_path) output = _mock_main(tmp_path)
@@ -243,3 +267,192 @@ class TestBackwardCompat:
assert tracks[1].name == "Delay" assert tracks[1].name == "Delay"
assert len(tracks[0].plugins) > 0 assert len(tracks[0].plugins) > 0
assert len(tracks[1].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}"

View File

@@ -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 import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1])) sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest 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: class TestSongDefinitionInstantiation:
@@ -135,3 +183,30 @@ class TestMidiNote:
"""MidiNote does NOT clamp — accepts any int (caller's responsibility).""" """MidiNote does NOT clamp — accepts any int (caller's responsibility)."""
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200) note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200)
assert note.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

448
tests/test_melody_engine.py Normal file
View File

@@ -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

View File

@@ -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 == ""

View File

@@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest import pytest
import tempfile 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 from src.reaper_builder import RPPBuilder
@@ -445,9 +445,9 @@ class TestVST3PresetData:
# Check that plugins WITH preset data have that data in output # Check that plugins WITH preset data have that data in output
from src.reaper_builder import PLUGIN_PRESETS, VST3_REGISTRY from src.reaper_builder import PLUGIN_PRESETS, VST3_REGISTRY
vst3_keys = set(VST3_REGISTRY.keys()) vst3_keys = set(VST3_REGISTRY.keys())
for name, preset_lines in PLUGIN_PRESETS.items(): for (name, role), preset_lines in PLUGIN_PRESETS.items():
# Only check VST3 plugins (skip VST2 plugins which are in the same dict now) # Only check VST3 plugins and default role (backward compat)
if name not in vst3_keys: if name not in vst3_keys or role != "":
continue continue
if len(preset_lines) > 0: if len(preset_lines) > 0:
# Check first preset line — most distinctive, no collision risk # 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" assert first_line in content, f"{name} preset line not found in output"
finally: finally:
Path(tmp_path).unlink(missing_ok=True) 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 <delta> 90 <pitch_hex> <vel_hex>
# 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)

View File

@@ -33,6 +33,152 @@ class TestSectionDef:
assert section.vol_mult == 0.6 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: class TestPluginRegistry:
def test_plugins_in_registry(self): def test_plugins_in_registry(self):
from src.reaper_builder import PLUGIN_REGISTRY from src.reaper_builder import PLUGIN_REGISTRY

View File

@@ -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: <TRACK opens on one line, NAME on the next line
# Names with spaces are quoted: NAME "808 Bass", single-word: NAME Drumloop
import re
track_names = re.findall(
r'<TRACK \{[^}]+\}\s*\n\s*NAME "?([^"\n]+)"?',
content,
)
assert "Clap" in track_names, f"Expected Clap in tracks: {track_names}"
assert "Transition FX" in track_names, f"Expected Transition FX in tracks: {track_names}"
assert "Pad" in track_names, f"Expected Pad in tracks: {track_names}"
clap_pos = track_names.index("Clap")
fx_pos = track_names.index("Transition FX")
pad_pos = track_names.index("Pad")
assert clap_pos < fx_pos < pad_pos, (
f"Expected Clap ({clap_pos}) < FX ({fx_pos}) < Pad ({pad_pos})"
)
def test_calibrate_does_not_break_fx_track(self, tmp_path):
"""Calibrator.apply() does not crash on FX track."""
output = _mock_main_fx(tmp_path)
content = output.read_text(encoding="utf-8")
assert "Transition FX" in content, (
"FX track must survive calibration"
)
# ============================================================================
# Helpers for integration tests
# ============================================================================
def _mock_main_fx(tmp_path, extra_args=None):
"""Mock compose.py main() with FX-capable selector."""
import sys as _sys
from unittest.mock import patch, MagicMock
output = tmp_path / "track.rpp"
# Build mock with FX samples that select_one will return
fx_samples = [
{
"role": "fx",
"perceptual": {"tempo": 0},
"musical": {"key": "X"},
"character": "dark",
"original_path": f"fx_sample_{i}.wav",
"original_name": f"Transition_FX_{i}.wav",
"file_hash": f"fx{i:04d}",
}
for i in range(10)
]
with patch("scripts.compose.SampleSelector") as mock_cls:
mock_sel = MagicMock()
mock_sel._samples = fx_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 select for clap builder
mock_sel.select.return_value = [
MagicMock(sample={
"original_path": "fake_clap.wav",
"file_hash": "clap123",
}),
]
# Mock select_one for FX builder — returns real dicts
mock_sel.select_one.return_value = fx_samples[0]
mock_sel.select_diverse.return_value = [
{
"original_path": "fake_vocal.wav",
"file_hash": "vox123",
},
]
mock_cls.return_value = mock_sel
from scripts.compose import main
original_argv = _sys.argv
try:
argv = ["compose", "--output", str(output)]
if extra_args:
argv.extend(extra_args)
_sys.argv = argv
main()
finally:
_sys.argv = original_argv
return output