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:
@@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent
|
||||
from src.reaper_builder import RPPBuilder
|
||||
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestDrumloopFirstTracks:
|
||||
def test_all_tracks_created(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
for name in ("Drumloop", "Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
|
||||
for name in ("Drumloop", "808 Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
|
||||
assert name in content, f"Expected track '{name}' in output"
|
||||
|
||||
def test_clap_on_dembow_beats(self, tmp_path):
|
||||
@@ -197,24 +197,48 @@ class TestDrumloopFirstTracks:
|
||||
for s in starts:
|
||||
assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat"
|
||||
|
||||
def test_melody_uses_pentatonic(self):
|
||||
from scripts.compose import build_melody_track
|
||||
def test_melody_uses_hook_structure(self):
|
||||
"""Lead melody should use hook-based call-response from melody_engine."""
|
||||
from scripts.compose import build_melody_track, get_pentatonic
|
||||
from src.core.schema import SectionDef
|
||||
from src.composer.melody_engine import _resolve_chord_tones
|
||||
|
||||
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_melody_track(sections, offsets, "A", True, seed=42)
|
||||
assert len(track.clips) > 0, "Melody should have clips"
|
||||
pitches = {n.pitch for n in track.clips[0].midi_notes}
|
||||
|
||||
notes = track.clips[0].midi_notes
|
||||
pitches = {n.pitch for n in notes}
|
||||
assert len(pitches) > 1, "Melody should use multiple notes"
|
||||
|
||||
# Verify chord-tone emphasis: quarter-position notes should favor chord tones
|
||||
penta = get_pentatonic("A", True, 4) + get_pentatonic("A", True, 5)
|
||||
chord_tones = _resolve_chord_tones("A", True, 0, 4)
|
||||
|
||||
quarter_notes = [n for n in notes if abs(n.start % 1.0) < 0.001]
|
||||
if quarter_notes:
|
||||
chord_count = sum(
|
||||
1 for n in quarter_notes
|
||||
if any(abs(n.pitch - ct) % 12 == 0 for ct in chord_tones)
|
||||
)
|
||||
ratio = chord_count / len(quarter_notes)
|
||||
assert ratio >= 0.5, (
|
||||
f"Hook chord tone ratio {ratio:.1%} below 50%"
|
||||
)
|
||||
|
||||
# Verify notes span the section length
|
||||
max_end = max(n.start + n.duration for n in notes)
|
||||
assert max_end <= 16.0 + 0.001, "Notes should fit within 4 bars"
|
||||
|
||||
def test_master_chain_present(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Pro-Q" in content, "Expected Pro-Q 3 in master chain"
|
||||
assert "Pro-C" in content, "Expected Pro-C 2 in master chain"
|
||||
assert "Pro-L" in content, "Expected Pro-L 2 in master chain"
|
||||
# After calibration, master chain uses Ozone 12 triplet (with spaces in RPP)
|
||||
assert "Ozone 12" in content or "Pro-Q" in content, (
|
||||
"Expected Ozone 12 or Pro-Q in master chain"
|
||||
)
|
||||
|
||||
def test_sends_wired(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
@@ -243,3 +267,192 @@ class TestBackwardCompat:
|
||||
assert tracks[1].name == "Delay"
|
||||
assert len(tracks[0].plugins) > 0
|
||||
assert len(tracks[1].plugins) > 0
|
||||
|
||||
|
||||
class TestSectionEnergy:
|
||||
"""Integration tests for section energy curve — sparse vs dense sections."""
|
||||
|
||||
def test_section_rename_pre_chorus_not_build(self):
|
||||
"""SECTIONS uses 'pre-chorus' not 'build'."""
|
||||
from scripts.compose import SECTIONS
|
||||
names = {name for name, _, _, _ in SECTIONS}
|
||||
assert "pre-chorus" in names, "Expected 'pre-chorus' section"
|
||||
assert "build" not in names, "'build' must be renamed to 'pre-chorus'"
|
||||
|
||||
def test_intro_has_no_bass(self):
|
||||
"""Intro section should NOT have bass (sparse)."""
|
||||
from scripts.compose import build_bass_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
intro_section = sections[0]
|
||||
assert intro_section.name == "intro"
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
# Find clips whose position matches the intro offset
|
||||
intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0]
|
||||
assert len(intro_positions) == 0, "Intro should have no bass clips"
|
||||
|
||||
def test_intro_has_no_chords(self):
|
||||
"""Intro section should NOT have chords (sparse)."""
|
||||
from scripts.compose import build_chords_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
track = build_chords_track(sections, offsets, "A", True)
|
||||
intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0]
|
||||
assert len(intro_positions) == 0, "Intro should have no chord clips"
|
||||
|
||||
def test_intro_has_no_lead(self):
|
||||
"""Intro section should NOT have lead (sparse)."""
|
||||
from scripts.compose import build_lead_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
track = build_lead_track(sections, offsets, "A", True, seed=42)
|
||||
intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0]
|
||||
assert len(intro_positions) == 0, "Intro should have no lead clips"
|
||||
|
||||
def test_chorus_has_bass_chords_lead(self):
|
||||
"""Chorus section should have bass, chords, and lead (full band)."""
|
||||
from scripts.compose import (
|
||||
build_bass_track, build_chords_track, build_lead_track,
|
||||
build_section_structure,
|
||||
)
|
||||
sections, offsets = build_section_structure()
|
||||
# Find the chorus section index (first "chorus")
|
||||
chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus")
|
||||
chorus_offset = offsets[chorus_idx] * 4.0
|
||||
|
||||
bass = build_bass_track(sections, offsets, "A", True)
|
||||
chords = build_chords_track(sections, offsets, "A", True)
|
||||
lead = build_lead_track(sections, offsets, "A", True, seed=42)
|
||||
|
||||
assert any(c.position == chorus_offset for c in bass.clips), "Chorus should have bass"
|
||||
assert any(c.position == chorus_offset for c in chords.clips), "Chorus should have chords"
|
||||
assert any(c.position == chorus_offset for c in lead.clips), "Chorus should have lead"
|
||||
|
||||
def test_chorus_clips_have_vol_mult(self):
|
||||
"""Clips in chorus sections should have vol_mult set from section."""
|
||||
from scripts.compose import build_drumloop_track, build_bass_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus")
|
||||
chorus_offset = offsets[chorus_idx] * 4.0
|
||||
|
||||
drumloop_track = build_drumloop_track(sections, offsets, seed=0)
|
||||
bass_track = build_bass_track(sections, offsets, "A", True)
|
||||
|
||||
# Audio clips get vol_mult
|
||||
dl_clips = [c for c in drumloop_track.clips if c.position == chorus_offset]
|
||||
if dl_clips:
|
||||
assert dl_clips[0].vol_mult == 1.0, "Chorus drumloop vol_mult should be 1.0"
|
||||
|
||||
# MIDI clips get vol_mult
|
||||
bass_clips = [c for c in bass_track.clips if c.position == chorus_offset]
|
||||
if bass_clips:
|
||||
assert bass_clips[0].vol_mult == 1.0, "Chorus bass vol_mult should be 1.0"
|
||||
|
||||
def test_velocity_scaled_in_intro_vs_chorus(self):
|
||||
"""Verse has lower velocity notes than chorus (velocity_mult 0.7 vs 1.0)."""
|
||||
from scripts.compose import build_bass_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
verse_idx = next(i for i, s in enumerate(sections) if s.name == "verse")
|
||||
verse_offset = offsets[verse_idx] * 4.0
|
||||
chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus")
|
||||
chorus_offset = offsets[chorus_idx] * 4.0
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
verse_clip = next(c for c in track.clips if c.position == verse_offset)
|
||||
chorus_clip = next(c for c in track.clips if c.position == chorus_offset)
|
||||
|
||||
verse_vel = verse_clip.midi_notes[0].velocity if verse_clip.midi_notes else 0
|
||||
chorus_vel = chorus_clip.midi_notes[0].velocity if chorus_clip.midi_notes else 0
|
||||
assert verse_vel < chorus_vel, \
|
||||
f"Verse velocity ({verse_vel}) should be less than chorus ({chorus_vel})"
|
||||
|
||||
def test_drumloop_assignments_no_break_key(self):
|
||||
"""DRUMLOOP_ASSIGNMENTS has no 'break' key — replaced by activity matrix."""
|
||||
from scripts.compose import DRUMLOOP_ASSIGNMENTS
|
||||
assert "break" not in DRUMLOOP_ASSIGNMENTS
|
||||
assert "pre-chorus" in DRUMLOOP_ASSIGNMENTS
|
||||
|
||||
|
||||
class TestSidechainBassCC:
|
||||
"""Integration tests for CC11 sidechain ducking on 808 bass."""
|
||||
|
||||
def test_bass_track_populates_midi_cc_with_kick_cache(self):
|
||||
"""build_bass_track populates midi_cc when kick cache present."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
kick_cache = {"fake_drumloop.wav": [1.0, 3.0, 5.0, 7.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
assert len(track.clips) > 0, "Bass should have clips"
|
||||
|
||||
verse_clip = track.clips[0]
|
||||
# With 4 kicks in range, each generates 3 CC events
|
||||
assert len(verse_clip.midi_cc) == 12, f"Expected 12 CC events (4 kicks × 3), got {len(verse_clip.midi_cc)}"
|
||||
|
||||
# Check first kick's duck events
|
||||
cc_times = [(cc.time, cc.value) for cc in verse_clip.midi_cc[:3]]
|
||||
assert (1.0, 50) in cc_times, f"Expected CC dip at 1.0, got {cc_times}"
|
||||
assert (1.02, 50) in cc_times, f"Expected CC hold at 1.02, got {cc_times}"
|
||||
assert (1.18, 127) in cc_times, f"Expected CC release at 1.18, got {cc_times}"
|
||||
|
||||
def test_bass_track_no_kick_cache_empty_cc(self):
|
||||
"""build_bass_track produces empty midi_cc when no kick cache provided."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
assert len(track.clips) > 0
|
||||
verse_clip = track.clips[0]
|
||||
assert verse_clip.midi_cc == [], "midi_cc should be empty without kick cache"
|
||||
|
||||
def test_bass_track_no_kicks_in_range(self):
|
||||
"""build_bass_track produces empty midi_cc when no kicks in clip range."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=2, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
# Kicks at beats far outside the clip range (0-8 beats)
|
||||
kick_cache = {"fake_drumloop.wav": [100.0, 200.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
verse_clip = track.clips[0]
|
||||
assert verse_clip.midi_cc == [], "midi_cc should be empty when kicks are outside clip range"
|
||||
|
||||
def test_bass_track_preserves_notes_with_cc(self):
|
||||
"""build_bass_track preserves existing note generation when CC added."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=4, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
kick_cache = {"fake_drumloop.wav": [2.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
verse_clip = track.clips[0]
|
||||
# Should still have bass notes (i - iv pattern for 4 bars)
|
||||
assert len(verse_clip.midi_notes) > 0, "Bass notes should still be generated"
|
||||
assert len(verse_clip.midi_cc) == 3, "Should have 3 CC events for 1 kick in range"
|
||||
|
||||
def test_bass_track_kicks_relative_to_clip(self):
|
||||
"""build_bass_track produces CC times relative to clip start, not absolute."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
# Section at offset 2 bars (8 beats)
|
||||
sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [2.0] # starts at bar 2 = beat 8
|
||||
|
||||
# Kick at absolute beat 9.0 → relative beat 1.0
|
||||
kick_cache = {"fake_drumloop.wav": [9.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
verse_clip = track.clips[0]
|
||||
assert len(verse_clip.midi_cc) == 3
|
||||
|
||||
# CC times should be relative to clip start (9.0 - 8.0 = 1.0)
|
||||
first_cc_time = verse_clip.midi_cc[0].time
|
||||
assert first_cc_time == 1.0, f"CC time should be 1.0 (relative), got {first_cc_time}"
|
||||
|
||||
Reference in New Issue
Block a user