"""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 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("= 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", "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_pentatonic(self): from scripts.compose import build_melody_track from src.core.schema import SectionDef 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} assert len(pitches) > 1, "Melody should use multiple notes" 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" 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