Files
reaper-control/tests/test_compose_integration.py
renato97 014e636889 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.
2026-05-03 23:54:29 -03:00

459 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration tests for scripts/compose.py — drumloop-first compose workflow."""
import sys
from pathlib import Path
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, CCEvent
from src.reaper_builder import RPPBuilder
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_analysis():
return DrumLoopAnalysis(
file_path="fake_drumloop.wav",
bpm=95.0,
duration=8.0,
beats=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
transients=[
Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100),
Transient(time=0.6316, type="hihat", energy=0.4, spectral_centroid=8000),
Transient(time=1.2632, type="snare", energy=0.7, spectral_centroid=3000),
Transient(time=1.8947, type="hihat", energy=0.3, spectral_centroid=7000),
Transient(time=2.5263, type="kick", energy=0.8, spectral_centroid=100),
Transient(time=3.1579, type="snare", energy=0.6, spectral_centroid=3500),
Transient(time=3.7895, type="hihat", energy=0.4, spectral_centroid=9000),
],
beat_grid=BeatGrid(
quarter=[0.0, 0.6316, 1.2632, 1.8947, 2.5263, 3.1579, 3.7895, 4.4211],
eighth=[i * 0.3158 for i in range(16)],
sixteenth=[i * 0.1579 for i in range(32)],
),
key="Am",
key_confidence=0.85,
energy_profile=[0.8, 0.4, 0.7, 0.3, 0.8, 0.6, 0.4, 0.3],
bar_count=2,
sample_rate=44100,
)
def _mock_main(tmp_path, extra_args=None):
output = tmp_path / "track.rpp"
fake_analysis = _fake_analysis()
with patch("scripts.compose.SampleSelector") as mock_cls:
mock_sel = MagicMock()
mock_sel._samples = [
{
"role": "drumloop",
"perceptual": {"tempo": 95.0},
"musical": {"key": "Am", "mode": "minor"},
"character": "dark",
"original_path": "fake_drumloop.wav",
"original_name": "fake_drumloop.wav",
"file_hash": "abc123",
},
{
"role": "snare",
"perceptual": {"tempo": 0},
"musical": {"key": "X"},
"character": "sharp",
"original_path": "fake_clap.wav",
"original_name": "fake_clap.wav",
"file_hash": "clap123",
},
{
"role": "vocal",
"perceptual": {"tempo": 95.0},
"musical": {"key": "Am", "mode": "minor"},
"character": "melodic",
"original_path": "fake_vocal.wav",
"original_name": "fake_vocal.wav",
"file_hash": "vox123",
},
]
mock_sel.select.return_value = [
MagicMock(sample={
"original_path": "fake_clap.wav",
"file_hash": "clap123",
}),
]
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
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestComposeRppOutput:
def test_compose_produces_rpp_file(self, tmp_path):
output = _mock_main(tmp_path)
assert output.exists(), f"Expected {output} to exist"
def test_compose_rpp_has_min_6_tracks(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
track_count = content.count("<TRACK")
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
def test_compose_has_fxchain(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
def test_compose_has_midi_source(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "SOURCE MIDI" in content, "Expected MIDI source in output"
def test_compose_has_audio_source(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
assert "SOURCE WAVE" in content, "Expected WAVE source in output"
def test_compose_invalid_bpm_raises(self, tmp_path):
with pytest.raises(ValueError, match="bpm must be > 0"):
_mock_main(tmp_path, ["--bpm", "0"])
def test_compose_negative_bpm_raises(self, tmp_path):
with pytest.raises(ValueError, match="bpm must be > 0"):
_mock_main(tmp_path, ["--bpm", "-10"])
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", "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):
from scripts.compose import build_clap_track, SECTIONS
from src.core.schema import SectionDef
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
offsets = [0.0]
mock_selector = MagicMock()
mock_selector.select.return_value = [
MagicMock(sample={"original_path": "clap.wav"}),
]
track = build_clap_track(mock_selector, sections, offsets)
positions = [c.position for c in track.clips]
assert 2.0 in positions, "Clap on beat 2 (backbeat)"
assert 3.5 in positions, "Clap on beat 3.5 (dembow)"
def test_bass_uses_kick_free_zones(self):
from scripts.compose import build_bass_track
from src.core.schema import SectionDef
analysis = _fake_analysis()
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
offsets = [0.0]
track = build_bass_track(sections, offsets, "A", True)
assert len(track.clips) > 0, "Bass should have clips"
assert all(n.duration == 1.5 for n in track.clips[0].midi_notes), "Bass notes should be 1.5 beats (808 pattern)"
def test_chords_change_on_downbeats(self):
from scripts.compose import build_chords_track
from src.core.schema import SectionDef
analysis = _fake_analysis()
sections = [SectionDef(name="verse", bars=8, energy=1.0)]
offsets = [0.0]
track = build_chords_track(sections, offsets, "A", True)
starts = sorted(set(n.start for n in track.clips[0].midi_notes))
for s in starts:
assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat"
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"
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")
# 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)
content = output.read_text(encoding="utf-8")
assert "AUXRECV" in content, "Expected send routing in output"
class TestBackwardCompat:
def test_imports_exist(self):
from scripts.compose import (
build_section_tracks, create_return_tracks, EFFECT_ALIASES,
build_fx_chain, build_sampler_plugin,
)
assert callable(build_section_tracks)
assert callable(create_return_tracks)
assert callable(build_fx_chain)
assert callable(build_sampler_plugin)
assert isinstance(EFFECT_ALIASES, dict)
def test_create_return_tracks(self):
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
assert len(tracks) == 2
assert tracks[0].name == "Reverb"
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}"