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:
50
.gga
Normal file
50
.gga
Normal 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"
|
||||||
125
.sdd/changes/hook-melody/design.md
Normal file
125
.sdd/changes/hook-melody/design.md
Normal 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, # 2–8 bars
|
||||||
|
seed: int = 42,
|
||||||
|
) -> list[MidiNote]:
|
||||||
|
"""Generate a 2–4 bar repeating motif using chord-aware scale selection."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def apply_variation(
|
||||||
|
motif: list[MidiNote],
|
||||||
|
shift_beats: float = 0.0,
|
||||||
|
transpose_semitones: int = 0,
|
||||||
|
) -> list[MidiNote]:
|
||||||
|
"""Apply rhythmic shift and/or pitch transpose to motif. Returns new list."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def build_call_response(
|
||||||
|
motif: list[MidiNote],
|
||||||
|
bars: int = 8,
|
||||||
|
key_root: str = "A",
|
||||||
|
key_minor: bool = True,
|
||||||
|
seed: int = 42,
|
||||||
|
) -> list[MidiNote]:
|
||||||
|
"""Build call-and-response structure: call (V/VII end) + response (i end)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
# compose.py retains exact signature:
|
||||||
|
def build_lead_track(
|
||||||
|
sections, offsets, key_root, key_minor, seed=0
|
||||||
|
) -> TrackDef:
|
||||||
|
# Sections with lead: chorus, chorus2, final (unchanged)
|
||||||
|
# Clips built via melody_engine.build_call_response()
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale & Chord Helpers (internal to melody_engine)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _resolve_chord_tones(root: str, is_minor: bool, bar: int) -> set[int]:
|
||||||
|
"""Return MIDI pitches for active chord at given bar index (from CHORD_PROGRESSION)."""
|
||||||
|
|
||||||
|
def _resolve_tension_notes(root: str, is_minor: bool, degree: str) -> int:
|
||||||
|
"""Return V or VII pitch for call-resolution scheme."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
| Layer | What to Test | Approach |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| Unit | `build_motif()` determinism | Same seed → identical output, different seed → different |
|
||||||
|
| Unit | `build_motif()` style validation | Invalid style → ValueError with message |
|
||||||
|
| Unit | `build_motif()` chord-tone ratio | Count notes on strong beats, assert ≥70% chord tones |
|
||||||
|
| Unit | `apply_variation()` identity | Note count preserved, durations preserved, IOIs preserved |
|
||||||
|
| Unit | `build_call_response()` resolution | Last note of call half = V/VII, last note overall = tonic |
|
||||||
|
| Unit | `build_call_response()` length | Notes span exactly `bars` parameter worth of beats |
|
||||||
|
| Integration | `build_lead_track()` delegation | Returns TrackDef with clips using call-response structure |
|
||||||
|
| Regression | Existing 110+ tests | All pass after updating melody assertion |
|
||||||
|
|
||||||
|
## Migration / Rollout
|
||||||
|
|
||||||
|
No migration required. `build_lead_track()` signature unchanged. Rollback = `git revert`.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None. All blocking decisions resolved above.
|
||||||
86
.sdd/changes/hook-melody/proposal.md
Normal file
86
.sdd/changes/hook-melody/proposal.md
Normal 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**: 2–4 bar motif repeated 2–4x with transpose/rhythmic-shift variations
|
||||||
|
- **Call-and-response**: first half = call (ends on V/VII), second half = response (resolves to i)
|
||||||
|
- **Chord-aware note selection**: strong beats (1, 3) favor chord tones; weak beats use scale passing tones
|
||||||
|
- **Replace `build_lead_track()`** in `compose.py` to delegate to the new engine
|
||||||
|
- **Tests** for deterministic output, motif identity preserved across variations, call-response resolution
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- MIDI velocity humanization / groove quantization
|
||||||
|
- User-selectable style at CLI (hardcoded to "hook" style initially)
|
||||||
|
- Chord progression generation (uses existing `CHORD_PROGRESSION` from compose.py)
|
||||||
|
- Pad/chords/bass refactoring — lead only
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `melody-engine`: Deterministic hook generation with motif, variation, call-response, and 3 reggaeton styles. Chord-aware via `CHORD_PROGRESSION` input.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- None at spec level. `build_lead_track()` API unchanged (same signature). Behavior changes from random to deterministic, but callers see same interface.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
New module `src/composer/melody_engine.py` with:
|
||||||
|
|
||||||
|
1. **`build_motif(key_root, key_minor, style, bars=4)`** → `list[MidiNote]`
|
||||||
|
- Style "hook": arch contour, chord tones on 0, 2, 4... beats, 4–8 notes
|
||||||
|
- Style "stabs": short 16th hits on [1.0, 2.5, 3.0, 3.5] per bar
|
||||||
|
- Style "smooth": stepwise scalar motion at eighth-note density
|
||||||
|
- Chords resolved from `CHORD_PROGRESSION` for chord-tone selection
|
||||||
|
|
||||||
|
2. **`apply_variation(motif, shift=0, transpose=0)`** → variation
|
||||||
|
- Rhythmic shift: offset within the grid
|
||||||
|
- Transpose: ±octave or ±third within scale
|
||||||
|
|
||||||
|
3. **`build_call_response(motif, sections, key_root, key_minor)`** → `list[ClipDef]`
|
||||||
|
- First half = call (motif + slight variation, ends on tension note)
|
||||||
|
- Second half = response (motif, resolves to tonic)
|
||||||
|
- Repeats for section length
|
||||||
|
|
||||||
|
`compose.py` `build_lead_track()` becomes thin wrapper calling `melody_engine`. All existing tests pass with updated expected values.
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
| Area | Impact | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `src/composer/melody_engine.py` | New | Hook engine — motifs, variations, call-response |
|
||||||
|
| `scripts/compose.py` | Modified | `build_lead_track()` delegates to melody_engine; `get_pentatonic()` stays as helper |
|
||||||
|
| `tests/test_compose_integration.py` | Modified | Update `test_melody_uses_pentatonic` to assert motif structure |
|
||||||
|
| `tests/test_section_builder.py` | None | `get_pentatonic` tests unaffected |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|------------|------------|
|
||||||
|
| Deterministic melody sounds repetitive | Med | 3 style options + variation params provide diversity; section energy scales velocity |
|
||||||
|
| Chord-awareness breaks if CHORD_PROGRESSION changes format | Low | Hardcoded in compose.py — same module owns both; integration test catches mismatch |
|
||||||
|
| Motif too short for long sections (8+ bars) | Low | Call-response repeats motif to fill bars; edge case validated in tests |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
Revert `build_lead_track()` to original random-pentatonic implementation (git revert). No schema or API changes — pure function replacement.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `CHORD_PROGRESSION` constant from `compose.py` (existing)
|
||||||
|
- `get_pentatonic()` helper from `compose.py` (kept, reused)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] `build_lead_track()` produces identical output for same seed+key input (deterministic)
|
||||||
|
- [ ] Generated melody contains a repeating 2–4 bar motif with ≤2 variations
|
||||||
|
- [ ] Call section ends on V or VII degree; response resolves to i
|
||||||
|
- [ ] Strong beats (quarter positions) use chord tones ≥70% of the time
|
||||||
|
- [ ] All 110+ existing tests pass
|
||||||
|
- [ ] 5+ new tests for melody_engine: motif identity, variation bounds, call-response resolution
|
||||||
121
.sdd/changes/hook-melody/spec.md
Normal file
121
.sdd/changes/hook-melody/spec.md
Normal 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 2–4 bar repeating motif using scale-aware note selection. Three styles:
|
||||||
|
|
||||||
|
- **hook**: Arch contour (ascend then descend), chord tones on beats 0, 2, 4..., 4–8 notes
|
||||||
|
- **stabs**: Short 16th-duration hits on dembow grid positions [1.0, 2.5, 3.0, 3.5] per bar
|
||||||
|
- **smooth**: Stepwise scalar motion at eighth-note density, ≤2 semitones between consecutive notes
|
||||||
|
|
||||||
|
MUST accept `bars` parameter (2–8) defaulting to 4. MUST return `list[MidiNote]`.
|
||||||
|
|
||||||
|
#### Scenario: hook style generates arch contour with chord tones
|
||||||
|
|
||||||
|
- GIVEN key Am, style "hook", bars=4, seed=42
|
||||||
|
- WHEN `build_motif("A", True, "hook", 4, 42)` is called
|
||||||
|
- THEN returns 4–12 MidiNote objects
|
||||||
|
- AND notes on quarter-beat positions (0, 2, 4, …) are within the i-VI-III-VII chord tones ≥70% of the time
|
||||||
|
|
||||||
|
#### Scenario: stabs style generates dembow-positioned hits
|
||||||
|
|
||||||
|
- GIVEN key Am, style "stabs", bars=2, seed=1
|
||||||
|
- WHEN `build_motif("A", True, "stabs", 2, 1)` is called
|
||||||
|
- THEN all note start times are within {1.0, 2.5, 3.0, 3.5} per bar
|
||||||
|
- AND each note duration ≤ 0.25 beats (16th note)
|
||||||
|
|
||||||
|
#### Scenario: smooth style generates stepwise motion
|
||||||
|
|
||||||
|
- GIVEN key Am, style "smooth", bars=4, seed=7
|
||||||
|
- WHEN `build_motif("A", True, "smooth", 4, 7)` is called
|
||||||
|
- THEN pitch difference between consecutive notes ≤ 2 semitones
|
||||||
|
|
||||||
|
#### Scenario: invalid style raises ValueError
|
||||||
|
|
||||||
|
- GIVEN an unrecognized style string
|
||||||
|
- WHEN `build_motif("A", True, "invalid", 4, 42)` is called
|
||||||
|
- THEN raises ValueError with message containing valid styles
|
||||||
|
|
||||||
|
### Requirement: Deterministic Output (R2)
|
||||||
|
|
||||||
|
`build_motif()` and `apply_variation()` MUST produce identical output for identical input parameters (key, style, bars, seed). MUST NOT rely on global RNG state.
|
||||||
|
|
||||||
|
#### Scenario: same seed produces identical output
|
||||||
|
|
||||||
|
- GIVEN fixed parameters
|
||||||
|
- WHEN `build_motif("A", True, "hook", 4, 42)` is called twice
|
||||||
|
- THEN both calls return identical lists of MidiNote objects
|
||||||
|
|
||||||
|
#### Scenario: different seeds produce different output
|
||||||
|
|
||||||
|
- GIVEN same key and style but different seeds
|
||||||
|
- WHEN `build_motif("A", True, "hook", 4, 42)` and `build_motif("A", True, "hook", 4, 99)` are called
|
||||||
|
- THEN the returned note lists differ
|
||||||
|
|
||||||
|
### Requirement: Call-and-Response Structure (R3)
|
||||||
|
|
||||||
|
`build_call_response(motif, bars, key_root, key_minor, seed)` MUST generate two halves: **call** (motif + variation, ending on V or VII degree) and **response** (motif, resolving to tonic i). Total length MUST equal `bars` parameter. SHALL repeat motif to fill section length.
|
||||||
|
|
||||||
|
#### Scenario: call ends on tension, response resolves
|
||||||
|
|
||||||
|
- GIVEN an Am hook motif, bars=8, seed=42
|
||||||
|
- WHEN `build_call_response(motif, 8, "A", True, 42)` is called
|
||||||
|
- THEN the last note of the first 4 bars has pitch in {E, G} (V or VII of Am)
|
||||||
|
- AND the last note of the final bar (bar 8) has pitch in {A} (tonic)
|
||||||
|
|
||||||
|
#### Scenario: fills section with motif repetition
|
||||||
|
|
||||||
|
- GIVEN a 2-bar motif and bars=8
|
||||||
|
- WHEN `build_call_response(motif, 8, "A", True, 42)` is called
|
||||||
|
- THEN returns notes spanning 8 bars total
|
||||||
|
- AND motif content repeats at least 2 times within the 8 bars
|
||||||
|
|
||||||
|
### Requirement: Chord-Aware Notes (R4)
|
||||||
|
|
||||||
|
Note selection on strong beats (quarter note positions 0, 4, 8, 12 per bar in 16th-note grid) MUST favor chord tones from `CHORD_PROGRESSION`. Weak beats (all other positions) MAY use any scale degree.
|
||||||
|
|
||||||
|
#### Scenario: strong beats favor chord tones
|
||||||
|
|
||||||
|
- GIVEN key Am (CHORD_PROGRESSION = i-VI-III-VII), style "hook", bars=8
|
||||||
|
- WHEN a motif is generated
|
||||||
|
- THEN ≥70% of notes starting on quarter-beat boundaries belong to active chord tones
|
||||||
|
|
||||||
|
### Requirement: Motif Variation (R5)
|
||||||
|
|
||||||
|
`apply_variation(motif, shift_beats, transpose_semitones)` SHOULD produce a recognizable variant of the input motif. `shift_beats` offsets all start times within the loop. `transpose_semitones` shifts pitches within the scale. MUST return `list[MidiNote]`.
|
||||||
|
|
||||||
|
#### Scenario: rhythmic shift preserves note count and structure
|
||||||
|
|
||||||
|
- GIVEN a 4-bar hook motif
|
||||||
|
- WHEN `apply_variation(motif, shift_beats=0.25)` is called
|
||||||
|
- THEN note count equals original
|
||||||
|
- AND all note durations equal original
|
||||||
|
- AND inter-onset intervals are preserved
|
||||||
|
|
||||||
|
#### Scenario: transpose within scale preserves motif contour
|
||||||
|
|
||||||
|
- GIVEN a 4-bar hook motif in Am
|
||||||
|
- WHEN `apply_variation(motif, transpose_semitones=3)` is called
|
||||||
|
- THEN all pitches are offset by ±3 semitones (within pentatonic scale)
|
||||||
|
|
||||||
|
### Requirement: build_lead_track() Delegation (R6)
|
||||||
|
|
||||||
|
`build_lead_track()` in `compose.py` MUST delegate to `melody_engine.build_call_response()` instead of generating random pentatonic notes directly. MUST keep identical function signature. MUST pass existing tests after adjusting expected note counts.
|
||||||
|
|
||||||
|
#### Scenario: build_lead_track uses call-response structure
|
||||||
|
|
||||||
|
- GIVEN seed=42, key Am, sections containing "chorus" and "final"
|
||||||
|
- WHEN `build_lead_track(sections, offsets, "A", True, 42)` is called
|
||||||
|
- THEN returned TrackDef clips contain notes organized as call-response phrases
|
||||||
|
- AND at least one clip has notes ending on tonic pitch
|
||||||
35
.sdd/changes/hook-melody/tasks.md
Normal file
35
.sdd/changes/hook-melody/tasks.md
Normal 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, 4–8 notes
|
||||||
|
- [x] 1.3 Implement "stabs" style: 16th-duration hits on dembow positions [1.0, 2.5, 3.0, 3.5] per bar
|
||||||
|
- [x] 1.4 Implement "smooth" style: stepwise scalar eighth-note motion
|
||||||
|
- [x] 1.5 Implement `apply_variation(motif, shift_beats, transpose_semitones)` → `list[MidiNote]`
|
||||||
|
- [x] 1.6 Implement `build_call_response(motif, bars, key_root, key_minor, seed)` → `list[MidiNote]`
|
||||||
|
- [x] 1.7 Wire internal helpers: `_resolve_chord_tones()`, `_resolve_tension_notes()`
|
||||||
|
|
||||||
|
## Phase 2: Integration
|
||||||
|
|
||||||
|
- [x] 2.1 Modify `build_lead_track()` in `scripts/compose.py` to delegate to `melody_engine.build_call_response()`
|
||||||
|
- [x] 2.2 Pass seed through to melody engine calls
|
||||||
|
- [x] 2.3 Keep `get_pentatonic()` and `CHORD_PROGRESSION` unchanged in compose.py
|
||||||
|
|
||||||
|
## Phase 3: Testing
|
||||||
|
|
||||||
|
- [x] 3.1 Create `tests/test_melody_engine.py` with `test_motif_deterministic` (same seed = same output)
|
||||||
|
- [x] 3.2 Test `test_motif_different_seeds_different_output`
|
||||||
|
- [x] 3.3 Test `test_invalid_style_raises_value_error`
|
||||||
|
- [x] 3.4 Test `test_hook_chord_tones_on_strong_beats` (≥70% ratio)
|
||||||
|
- [x] 3.5 Test `test_stabs_grid_alignment` (all notes on dembow positions)
|
||||||
|
- [x] 3.6 Test `test_smooth_stepwise_motion` (consecutive ≤2 semitones)
|
||||||
|
- [x] 3.7 Test `test_variation_preserves_note_count_structure`
|
||||||
|
- [x] 3.8 Test `test_call_ends_on_tension_response_ends_on_tonic` (V/VII → i)
|
||||||
|
- [x] 3.9 Test `test_call_response_fills_bars` (motif repeats to fill section)
|
||||||
|
- [x] 3.10 Update `test_melody_uses_pentatonic` in `tests/test_compose_integration.py` for hook structure
|
||||||
|
|
||||||
|
## Phase 4: Validation
|
||||||
|
|
||||||
|
- [x] 4.1 Run full test suite: `pytest tests/ -x` — 247/248 pass (1 pre-existing failure, unrelated)
|
||||||
|
- [ ] 4.2 Manual verification: generate .rpp with `--seed 42`, confirm lead clips contain repeating motif structure
|
||||||
101
.sdd/changes/mix-calibration/design.md
Normal file
101
.sdd/changes/mix-calibration/design.md
Normal 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.
|
||||||
87
.sdd/changes/mix-calibration/proposal.md
Normal file
87
.sdd/changes/mix-calibration/proposal.md
Normal 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)
|
||||||
106
.sdd/changes/mix-calibration/spec.md
Normal file
106
.sdd/changes/mix-calibration/spec.md
Normal 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.0–1.0 range.
|
||||||
|
|
||||||
|
| Role | Volume | Target |
|
||||||
|
|------|--------|--------|
|
||||||
|
| drumloop | 0.85 | kick prominence |
|
||||||
|
| bass | 0.72 | sub-presence |
|
||||||
|
| chords | 0.78 | harmonic support |
|
||||||
|
| lead | 0.78 | melody clarity |
|
||||||
|
| clap | 0.75 | transient punch |
|
||||||
|
| pad | 0.68 | ambient depth |
|
||||||
|
| perc | 0.72 | groove feel |
|
||||||
|
|
||||||
|
#### Scenario: Unknown track role
|
||||||
|
|
||||||
|
- GIVEN a track with name not matching any preset role
|
||||||
|
- WHEN calibrated
|
||||||
|
- THEN the track's volume and pan remain unchanged (preserved as-is)
|
||||||
|
|
||||||
|
### Requirement: HPF/LPF EQ per Role
|
||||||
|
|
||||||
|
The system SHALL prepend a ReaEQ `PluginDef` to each non-return track's `plugins` list with appropriate HPF or LPF parameters. Bass tracks (808 Bass) SHALL receive LPF. All other tracks SHALL receive HPF.
|
||||||
|
|
||||||
|
#### Scenario: HPF on lead/chords/pad tracks
|
||||||
|
|
||||||
|
- GIVEN a track named "Chords", "Lead", "Pad", "Clap", "Perc", or "Drumloop"
|
||||||
|
- WHEN calibrated
|
||||||
|
- THEN a ReaEQ plugin is inserted at `plugins[0]` with param `0=1` (band enabled), `1=1` (HPF type), `2=200.0` (frequency for melodic) or `2=60.0` (drums)
|
||||||
|
|
||||||
|
#### Scenario: LPF on bass track
|
||||||
|
|
||||||
|
- GIVEN a track named "808 Bass"
|
||||||
|
- WHEN calibrated
|
||||||
|
- THEN a ReaEQ plugin is inserted at `plugins[0]` with param `0=1`, `1=0` (LPF type), `2=300.0` (frequency)
|
||||||
|
|
||||||
|
#### Scenario: Return tracks excluded
|
||||||
|
|
||||||
|
- GIVEN tracks named "Reverb" or "Delay"
|
||||||
|
- WHEN calibrated
|
||||||
|
- THEN no ReaEQ plugin is added (return tracks are skipped)
|
||||||
|
|
||||||
|
### Requirement: Stereo Width per Role
|
||||||
|
|
||||||
|
The system SHALL set track pan values to role-specific defaults.
|
||||||
|
|
||||||
|
| Role | Pan | Rationale |
|
||||||
|
|------|-----|-----------|
|
||||||
|
| drumloop | 0.0 | mono center |
|
||||||
|
| bass | 0.0 | mono sub |
|
||||||
|
| chords | +0.5 | wide right |
|
||||||
|
| lead | +0.3 | right-leaning |
|
||||||
|
| clap | -0.15 | off-center left |
|
||||||
|
| pad | -0.5 | wide left |
|
||||||
|
| perc | +0.12 | slight right |
|
||||||
|
|
||||||
|
### Requirement: Send Calibration
|
||||||
|
|
||||||
|
The system SHALL set `send_level` dict entries for reverb (index=return_track_count) and delay (index=return_track_count+1) on each non-return track.
|
||||||
|
|
||||||
|
| Role | Reverb | Delay |
|
||||||
|
|------|--------|-------|
|
||||||
|
| drumloop | 0.10 | 0.00 |
|
||||||
|
| bass | 0.05 | 0.02 |
|
||||||
|
| chords | 0.30 | 0.10 |
|
||||||
|
| lead | 0.25 | 0.15 |
|
||||||
|
| clap | 0.10 | 0.00 |
|
||||||
|
| pad | 0.40 | 0.20 |
|
||||||
|
| perc | 0.10 | 0.00 |
|
||||||
|
|
||||||
|
### Requirement: Master Chain Upgrade
|
||||||
|
|
||||||
|
The system SHALL replace `master_plugins` with `["Ozone_12_Equalizer","Ozone_12_Dynamics","Ozone_12_Maximizer"]`. If registry lookup for any Ozone plugin fails, the system MUST fall back to `["Pro-Q_3","Pro-C_2","Pro-L_2"]`.
|
||||||
|
|
||||||
|
### Requirement: Calibration Toggle
|
||||||
|
|
||||||
|
The system SHALL support a `--no-calibrate` CLI flag. When passed, `Calibrator.apply()` MUST NOT be called. When omitted (default), calibration MUST run. `SongMeta` MAY include an optional `calibrate: bool` field defaulting to `True`.
|
||||||
|
|
||||||
|
#### Scenario: --no-calibrate preserves existing behavior
|
||||||
|
|
||||||
|
- GIVEN `compose.py --no-calibrate -o out.rpp`
|
||||||
|
- WHEN the song is built
|
||||||
|
- THEN `Calibrator.apply()` is never invoked
|
||||||
|
- AND the generated `.rpp` matches the pre-calibration baseline
|
||||||
30
.sdd/changes/mix-calibration/tasks.md
Normal file
30
.sdd/changes/mix-calibration/tasks.md
Normal 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
|
||||||
80
.sdd/changes/presets-pack/design.md
Normal file
80
.sdd/changes/presets-pack/design.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Design: presets-pack
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
Restructure `PLUGIN_PRESETS` to `{(plugin, role): chunks}`, add `PresetTransformer` class with per-plugin decoders (Serum=JSON, SoundToys=key=value text, Omnisphere=SynthMaster text), and thread `role` parameter through `make_plugin()` → `_build_plugin()`. No new dependencies — pure Python `base64`, `json`, `re`.
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Data structure | `dict[tuple[str,str], list[str]]` — `{(plugin, role): chunks}` | Avoids dict-of-dicts nesting; simpler iteration in tests |
|
||||||
|
| Transformation | Separate `PresetTransformer` class per plugin format | Serum/JSON, SoundToys/text, Omnisphere/text are different parsers; isolation = testability |
|
||||||
|
| Role threading | Optional `role` param on `make_plugin()` and `_build_plugin()` | Zero breaking changes; None = current behavior |
|
||||||
|
| Fallback chain | role → default → None | Backward compatible; existing tests don't break |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
compose.py: make_plugin("Serum_2", 0, role="bass")
|
||||||
|
→ _resolve_preset("Serum_2", "bass")
|
||||||
|
→ PLUGIN_PRESETS[("Serum_2", "bass")] ← PresetTransformer output
|
||||||
|
→ PluginDef(preset_data=bass_chunks)
|
||||||
|
|
||||||
|
reaper_builder: _build_plugin(plugin)
|
||||||
|
→ entry = PLUGIN_REGISTRY.get(resolved_name)
|
||||||
|
→ preset_data = PLUGIN_PRESETS.get((resolved_name, plugin.role))
|
||||||
|
or PLUGIN_PRESETS.get((resolved_name, "default"))
|
||||||
|
→ _build_plugin_element(display, file, uid, preset_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `src/reaper_builder/__init__.py` | Modify | Restructure `PLUGIN_PRESETS` to `{(k,role): chunks}`; `_build_plugin()` reads `plugin.role` for lookup |
|
||||||
|
| `src/reaper_builder/preset_transformer.py` | Create | `PresetTransformer` class with `decode()`, `transform(role)`, `encode()`; per-plugin transformers |
|
||||||
|
| `src/composer/templates.py` | Modify | `_parse_vst_block()` and `_make_plugin_template()` handle new `PLUGIN_PRESETS` structure |
|
||||||
|
| `scripts/compose.py` | Modify | `make_plugin()` accepts `role` param, threads from `FX_CHAINS` key |
|
||||||
|
| `src/core/schema.py` | Modify | `PluginDef` gets optional `role: str \| None = None` field |
|
||||||
|
| `tests/test_preset_transform.py` | Create | Round-trip tests for 3 plugins × N roles |
|
||||||
|
|
||||||
|
## PresetTransformer Design
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PresetTransformer:
|
||||||
|
TRANSFORMERS: dict[str, Callable] = {
|
||||||
|
"Serum_2": _transform_serum,
|
||||||
|
"Decapitator": _transform_decapitator,
|
||||||
|
"Omnisphere": _transform_omnisphere,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def derive(plugin: str, default_chunks: list[str], role: str) -> list[str]:
|
||||||
|
transformer = PresetTransformer.TRANSFORMERS.get(plugin)
|
||||||
|
if not transformer:
|
||||||
|
return default_chunks # no transform = use default unchanged
|
||||||
|
return transformer(default_chunks, role)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-transformer functions:
|
||||||
|
- `_transform_serum(chunks, role)` — decode JSON body, modify `processor.osc.type`, `processor.filter.cutoff`, `processor.fx`
|
||||||
|
- `_transform_decapitator(chunks, role)` — decode text body, modify `Drive`, `Tone`, `Style` lines
|
||||||
|
- `_transform_omnisphere(chunks, role)` — decode SynthMaster body, modify `Atk`, `Dec`, `Filter Freq` lines
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
| Layer | What | Approach |
|
||||||
|
|-------|------|----------|
|
||||||
|
| Unit | PresetTransformer per plugin | decode → modify → encode for each (plugin, role); verify JSON/keys changed |
|
||||||
|
| Integration | make_plugin + _build_plugin with role | Build PluginDef, verify preset_data differs per role |
|
||||||
|
| Regression | `test_make_plugin_known_key` | Existing tests pass unchanged (role=None fallback) |
|
||||||
|
| Round-trip | encode(decode(chunks)) == chunks | Each plugin × role; verify chunk count, base64 charset, structure integrity |
|
||||||
|
|
||||||
|
## Migration / Rollout
|
||||||
|
|
||||||
|
No migration required. `PluginDef.role` defaults to None. Existing callers that don't pass role continue working with `"default"` preset. Revert: flatten `PLUGIN_PRESETS` back to single-level dict, remove `role` param.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
73
.sdd/changes/presets-pack/proposal.md
Normal file
73
.sdd/changes/presets-pack/proposal.md
Normal 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
|
||||||
80
.sdd/changes/presets-pack/spec.md
Normal file
80
.sdd/changes/presets-pack/spec.md
Normal 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)
|
||||||
23
.sdd/changes/presets-pack/tasks.md
Normal file
23
.sdd/changes/presets-pack/tasks.md
Normal 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.
|
||||||
99
.sdd/changes/section-energy/design.md
Normal file
99
.sdd/changes/section-energy/design.md
Normal 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.
|
||||||
90
.sdd/changes/section-energy/proposal.md
Normal file
90
.sdd/changes/section-energy/proposal.md
Normal 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
|
||||||
134
.sdd/changes/section-energy/spec.md
Normal file
134
.sdd/changes/section-energy/spec.md
Normal 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`
|
||||||
32
.sdd/changes/section-energy/tasks.md
Normal file
32
.sdd/changes/section-energy/tasks.md
Normal 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)
|
||||||
82
.sdd/changes/sidechain/design.md
Normal file
82
.sdd/changes/sidechain/design.md
Normal 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 # 0–127
|
||||||
|
|
||||||
|
# ClipDef — new field
|
||||||
|
midi_cc: list[CCEvent] = field(default_factory=list)
|
||||||
|
|
||||||
|
# compose.py — new function
|
||||||
|
def _get_kick_cache(drumloop_paths: list[str], bpm: float) -> dict[str, list[float]]:
|
||||||
|
"""Analyze drumloops, return {path: [kick_time_beats]}."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## E-line Encoding Detail
|
||||||
|
|
||||||
|
Current: `E {delta_ticks} {status} {data1} {data2}`
|
||||||
|
Note on: `E 480 90 3c 50` (note 60, vel 80, delta=480 ticks)
|
||||||
|
Note off: `E 960 80 3c 00`
|
||||||
|
CC11: `E 0 B0 0B 32` (controller 11=B, CC message 0xB0, val 50=0x32)
|
||||||
|
|
||||||
|
Merging: sort `[(n.start, "n", note), (c.time, "c", cc), ...]` by time. CC events contribute zero to cursor (no duration — delta-only).
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
| Layer | What | Approach |
|
||||||
|
|-------|------|----------|
|
||||||
|
| Unit | `CCEvent` dataclass | Round-trip serialization, default values |
|
||||||
|
| Unit | `_build_midi_source` CC emission | Feed `ClipDef` with CC events, parse output for `B0 0B` lines |
|
||||||
|
| Integration | `build_bass_track` with kick cache | Mock `DrumLoopAnalyzer`, verify `midi_cc` populated |
|
||||||
|
| E2E | Full pipeline with real drumloop | Generate .rpp, grep for `B0 0B` in output, verify in REAPER |
|
||||||
|
|
||||||
|
## Migration / Rollout
|
||||||
|
|
||||||
|
No migration required. `midi_cc` defaults to empty list — all existing code paths unchanged. One-commit revert: remove `midi_cc` field, revert builder merge, delete `_get_kick_cache()`.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
101
.sdd/changes/sidechain/proposal.md
Normal file
101
.sdd/changes/sidechain/proposal.md
Normal 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
|
||||||
76
.sdd/changes/sidechain/spec.md
Normal file
76
.sdd/changes/sidechain/spec.md
Normal 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
|
||||||
26
.sdd/changes/sidechain/tasks.md
Normal file
26
.sdd/changes/sidechain/tasks.md
Normal 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
|
||||||
98
.sdd/changes/smart-chords/design.md
Normal file
98
.sdd/changes/smart-chords/design.md
Normal 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.
|
||||||
75
.sdd/changes/smart-chords/proposal.md
Normal file
75
.sdd/changes/smart-chords/proposal.md
Normal 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
|
||||||
47
.sdd/changes/smart-chords/spec.md
Normal file
47
.sdd/changes/smart-chords/spec.md
Normal 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
|
||||||
27
.sdd/changes/smart-chords/tasks.md
Normal file
27
.sdd/changes/smart-chords/tasks.md
Normal 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)
|
||||||
88
.sdd/changes/transitions-fx/design.md
Normal file
88
.sdd/changes/transitions-fx/design.md
Normal 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 (7–9) |
|
||||||
|
| Sample selection | Weighted random top-5 | Pinned specific files | Variety across runs; selector scoring already works |
|
||||||
|
| Boundary timing | Hardcoded beat-offset map | Audio analysis | Section structure is deterministic; bar counts are fixed |
|
||||||
|
| Riser+impact at chorus | Two clips, same boundary | Single combined clip | Requires different timing; riser before boundary, impact on it |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SECTIONS → offsets (bar → beat)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FX_TRANSITIONS map: {boundary_idx: (type, start_offset, length, fade_in, fade_out)}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
build_fx_track(sections, offsets, selector, seed)
|
||||||
|
├── for each entry in FX_TRANSITIONS:
|
||||||
|
│ ├── boundary_beat = offsets[boundary_idx] * 4
|
||||||
|
│ ├── position = boundary_beat + start_offset
|
||||||
|
│ ├── sample = selector.select_one(role="fx", seed=seed+idx)
|
||||||
|
│ └── ClipDef(position, length, audio_path, fade_in, fade_out)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
TrackDef("Transition FX", volume=0.72, clips=[...], send_level={...})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundary → FX Map
|
||||||
|
|
||||||
|
| # | Boundary | Beat | FX Type | Position | Length | Fade In | Fade Out |
|
||||||
|
|---|----------|------|---------|----------|--------|---------|----------|
|
||||||
|
| 2 | verse→build | 48 | sweep | 46 | 2 | 0.3 | 0.0 |
|
||||||
|
| 3 | build→chorus | 64 | **riser** | 60 | 4 | 1.5 | 0.0 |
|
||||||
|
| 3 | build→chorus | 64 | **impact** | 64 | 2 | 0.0 | 0.3 |
|
||||||
|
| 4 | chorus→verse2 | 96 | transition | 94 | 2 | 0.2 | 0.2 |
|
||||||
|
| 5 | verse2→chorus2 | 128 | riser | 124 | 4 | 1.0 | 0.0 |
|
||||||
|
| 6 | chorus2→bridge | 160 | sweep | 158 | 2 | 0.2 | 0.2 |
|
||||||
|
| 7 | bridge→final | 176 | riser | 172 | 4 | 1.0 | 0.0 |
|
||||||
|
| 8 | final→outro | 208 | sweep | 206 | 2 | 0.3 | 0.5 |
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `scripts/compose.py` | Modify | Add `FX_TRANSITIONS` dict + `build_fx_track()` (~50 lines); call in `main()` after clap track, before return tracks |
|
||||||
|
|
||||||
|
## Key Implementation Detail
|
||||||
|
|
||||||
|
`SampleSelector.select_one()` has a `seed` kwarg — new in the selector API. If not yet supported, use `select(role="fx", limit=5)` with manual `random.choice()`. Since FX is in `ATONAL_ROLES`, key compatibility scoring is skipped (neutral 0.5).
|
||||||
|
|
||||||
|
## Track Ordering
|
||||||
|
|
||||||
|
```
|
||||||
|
tracks = [
|
||||||
|
build_drumloop_track(...), # 0
|
||||||
|
build_perc_track(...), # 1
|
||||||
|
build_bass_track(...), # 2
|
||||||
|
build_chords_track(...), # 3
|
||||||
|
build_lead_track(...), # 4
|
||||||
|
build_clap_track(...), # 5
|
||||||
|
build_fx_track(...), # 6 ← NEW
|
||||||
|
build_pad_track(...), # 7
|
||||||
|
]
|
||||||
|
return_tracks = create_return_tracks() # 8 (Reverb), 9 (Delay)
|
||||||
|
```
|
||||||
|
|
||||||
|
Send wiring applies to all non-return tracks automatically via existing loop. FX track sends: Reverb=0.08, Delay=0.05.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
| Layer | What | How |
|
||||||
|
|-------|------|-----|
|
||||||
|
| Unit | `build_fx_track` returns TrackDef with 8 clips | Mock selector via `SampleSelector.__init__` patching |
|
||||||
|
| Unit | Clip positions match boundary map | Assert `clip.position` values equal expected beats |
|
||||||
|
| Integration | End-to-end .rpp output | `compose.py --bpm 99 --key Am --output test.rpp`; grep for "Transition FX" `<TRACK` block |
|
||||||
|
| Existing | 110 tests pass | `pytest` before/after regression |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — all dependencies exist today (`SampleSelector`, `ClipDef.fade_in/out`, `SECTIONS` structure).
|
||||||
77
.sdd/changes/transitions-fx/proposal.md
Normal file
77
.sdd/changes/transitions-fx/proposal.md
Normal 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: 2–4 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.0–1.7s)
|
||||||
|
- Wash/noise: `fx_G#6_136_aggressive` (3.3s)
|
||||||
|
- Short shots/gates: "CAMTAZO 1–2" (1.5–2.0s), "PUERTA" (0.2s)
|
||||||
|
|
||||||
|
Place audio clips on a new "FX" track at section boundaries:
|
||||||
|
- **Riser/wash**: starts 2–4 beats BEFORE boundary, ends on boundary downbeat
|
||||||
|
- **Impact**: starts on boundary downbeat, short duration (1–2 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
|
||||||
95
.sdd/changes/transitions-fx/spec.md
Normal file
95
.sdd/changes/transitions-fx/spec.md
Normal 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 2–4 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 1–2 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 1–2 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)
|
||||||
27
.sdd/changes/transitions-fx/tasks.md
Normal file
27
.sdd/changes/transitions-fx/tasks.md
Normal 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
22
AGENTS.md
Normal 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
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
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
49
scripts/_match_samples.py
Normal 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()
|
||||||
@@ -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
185
src/calibrator/__init__.py
Normal 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
63
src/calibrator/presets.py
Normal 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.0–1.0 REAPER volume)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VOLUME_PRESETS: dict[str, float] = {
|
||||||
|
"drumloop": 0.85,
|
||||||
|
"bass": 0.82,
|
||||||
|
"chords": 0.75,
|
||||||
|
"lead": 0.80,
|
||||||
|
"clap": 0.78,
|
||||||
|
"pad": 0.70,
|
||||||
|
"perc": 0.80,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EQ presets — ReaEQ VST2 param slot → value
|
||||||
|
# Slot 0: band enabled (1=on)
|
||||||
|
# Slot 1: filter type (0=LPF, 1=HPF)
|
||||||
|
# Slot 2: frequency (Hz)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EQ_PRESETS: dict[str, dict[int, float]] = {
|
||||||
|
"drumloop": {0: 1, 1: 1, 2: 60.0}, # HPF 60Hz
|
||||||
|
"bass": {0: 1, 1: 0, 2: 300.0}, # LPF 300Hz
|
||||||
|
"chords": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz
|
||||||
|
"lead": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz
|
||||||
|
"clap": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz
|
||||||
|
"pad": {0: 1, 1: 1, 2: 100.0}, # HPF 100Hz
|
||||||
|
"perc": {0: 1, 1: 1, 2: 200.0}, # HPF 200Hz
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pan presets (-1.0 to 1.0)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PAN_PRESETS: dict[str, float] = {
|
||||||
|
"drumloop": 0.0,
|
||||||
|
"bass": 0.0,
|
||||||
|
"chords": 0.5,
|
||||||
|
"lead": 0.3,
|
||||||
|
"clap": -0.15,
|
||||||
|
"pad": -0.5,
|
||||||
|
"perc": 0.12,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Send presets — (reverb_send, delay_send) tuples (0.0–1.0)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SEND_PRESETS: dict[str, tuple[float, float]] = {
|
||||||
|
"drumloop": (0.10, 0.00),
|
||||||
|
"bass": (0.05, 0.00),
|
||||||
|
"chords": (0.40, 0.10),
|
||||||
|
"lead": (0.30, 0.15),
|
||||||
|
"clap": (0.10, 0.00),
|
||||||
|
"pad": (0.50, 0.20),
|
||||||
|
"perc": (0.10, 0.00),
|
||||||
|
}
|
||||||
217
src/composer/chords.py
Normal file
217
src/composer/chords.py
Normal 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
|
||||||
508
src/composer/melody_engine.py
Normal file
508
src/composer/melody_engine.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: 0–127
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
102
src/reaper_builder/preset_transformer.py
Normal file
102
src/reaper_builder/preset_transformer.py
Normal 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
631
tests/test_calibrator.py
Normal 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
312
tests/test_chords.py
Normal 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"
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
448
tests/test_melody_engine.py
Normal 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
|
||||||
194
tests/test_preset_transform.py
Normal file
194
tests/test_preset_transform.py
Normal 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 == ""
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
476
tests/test_transitions_fx.py
Normal file
476
tests/test_transitions_fx.py
Normal 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
|
||||||
Reference in New Issue
Block a user