"""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("= 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}"