- 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.
459 lines
20 KiB
Python
459 lines
20 KiB
Python
"""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}"
|