feat: drumloop-first generation with forensic analysis
- Add DrumLoopAnalyzer: extracts BPM, transients, key, beat grid from drumloops - Rewrite compose.py: drumloop drives everything (BPM, key, rhythm) - Bass tresillo pattern placed in kick-free zones - Chords change on downbeats matching drumloop key - Melody avoids transients, emphasizes chord tones - Vocal chops between transients, clap on dembow (beats 2, 3.5) - Remove COLOR token (not recognized by REAPER) - 90 tests passing, generates drumloop_song.rpp with 10 tracks, 20 plugins
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Integration tests for scripts/compose.py — end-to-end compose workflow."""
|
||||
"""Integration tests for scripts/compose.py — drumloop-first compose workflow."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -9,49 +9,107 @@ 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 compose_via_builder(
|
||||
genre: str = "reggaeton",
|
||||
bpm: float = 95.0,
|
||||
key: str = "Am",
|
||||
output_path: str = "output/track.rpp",
|
||||
) -> SongDefinition:
|
||||
"""Build a SongDefinition the same way scripts/compose.py does, return it.
|
||||
|
||||
This lets us test the compose logic without hitting the filesystem for samples.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path as P
|
||||
|
||||
_ROOT = P(__file__).parent.parent
|
||||
|
||||
from src.composer.rhythm import get_notes
|
||||
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
||||
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
||||
|
||||
genre_path = _ROOT / "knowledge" / "genres" / f"{genre.lower()}_2009.json"
|
||||
with open(genre_path, "r", encoding="utf-8") as f:
|
||||
genre_config = json.load(f)
|
||||
|
||||
from scripts.compose import (
|
||||
build_section_tracks, create_return_tracks, EFFECT_ALIASES,
|
||||
build_fx_chain, build_sampler_plugin,
|
||||
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,
|
||||
)
|
||||
from src.selector import SampleSelector
|
||||
|
||||
index_path = _ROOT / "data" / "sample_index.json"
|
||||
selector = SampleSelector(str(index_path))
|
||||
|
||||
tracks, sections = build_section_tracks(genre_config, selector, key, bpm)
|
||||
return_tracks = create_return_tracks()
|
||||
def _mock_main(tmp_path, extra_args=None):
|
||||
output = tmp_path / "track.rpp"
|
||||
fake_analysis = _fake_analysis()
|
||||
|
||||
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
|
||||
return SongDefinition(meta=meta, tracks=tracks + return_tracks, sections=sections)
|
||||
with patch("scripts.compose.SampleSelector") as mock_cls, \
|
||||
patch("scripts.compose.DrumLoopAnalyzer") as mock_analyzer_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
|
||||
|
||||
mock_analyzer = MagicMock()
|
||||
mock_analyzer.analyze.return_value = fake_analysis
|
||||
mock_analyzer_cls.return_value = mock_analyzer
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,168 +117,135 @@ def compose_via_builder(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComposeRppOutput:
|
||||
"""Tests for compose workflow producing valid .rpp output."""
|
||||
|
||||
def test_compose_produces_rpp_file(self, tmp_path):
|
||||
"""main() with valid args produces a .rpp file at the output path."""
|
||||
output = tmp_path / "track.rpp"
|
||||
|
||||
# Mock SampleSelector.select_one so we don't need actual sample files
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
||||
mock_selector = MagicMock()
|
||||
mock_selector.select_one.return_value = None # audio_path stays None
|
||||
mock_selector_cls.return_value = mock_selector
|
||||
|
||||
from scripts.compose import main
|
||||
import sys
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"compose",
|
||||
"--genre", "reggaeton",
|
||||
"--bpm", "95",
|
||||
"--key", "Am",
|
||||
"--output", str(output),
|
||||
]
|
||||
main()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
output = _mock_main(tmp_path)
|
||||
assert output.exists(), f"Expected {output} to exist"
|
||||
|
||||
def test_compose_rpp_has_min_6_tracks(self, tmp_path):
|
||||
"""The .rpp output contains at least 6 <TRACK blocks (roles + 2 returns)."""
|
||||
output = tmp_path / "track.rpp"
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
||||
mock_selector = MagicMock()
|
||||
mock_selector.select_one.return_value = None
|
||||
mock_selector_cls.return_value = mock_selector
|
||||
|
||||
from scripts.compose import main
|
||||
import sys
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"compose",
|
||||
"--genre", "reggaeton",
|
||||
"--bpm", "95",
|
||||
"--key", "Am",
|
||||
"--output", str(output),
|
||||
]
|
||||
main()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
track_count = content.count("<TRACK")
|
||||
# 6 roles + 2 return tracks = 8 minimum
|
||||
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
|
||||
|
||||
def test_compose_has_fxchain(self, tmp_path):
|
||||
"""The .rpp output contains FXCHAIN elements."""
|
||||
output = tmp_path / "track.rpp"
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
||||
mock_selector = MagicMock()
|
||||
mock_selector.select_one.return_value = None
|
||||
mock_selector_cls.return_value = mock_selector
|
||||
|
||||
from scripts.compose import main
|
||||
import sys
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"compose",
|
||||
"--genre", "reggaeton",
|
||||
"--bpm", "95",
|
||||
"--key", "Am",
|
||||
"--output", str(output),
|
||||
]
|
||||
main()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
|
||||
|
||||
def test_compose_invalid_bpm_raises(self):
|
||||
"""main() with bpm=0 raises ValueError."""
|
||||
from scripts.compose import main
|
||||
import sys
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"compose",
|
||||
"--genre", "reggaeton",
|
||||
"--bpm", "0",
|
||||
"--key", "Am",
|
||||
"--output", "output/track.rpp",
|
||||
]
|
||||
with pytest.raises(ValueError, match="bpm must be > 0"):
|
||||
main()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
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_negative_bpm_raises(self):
|
||||
"""main() with bpm=-10 raises ValueError."""
|
||||
from scripts.compose import main
|
||||
import sys
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"compose",
|
||||
"--genre", "reggaeton",
|
||||
"--bpm", "-10",
|
||||
"--key", "Am",
|
||||
"--output", "output/track.rpp",
|
||||
]
|
||||
with pytest.raises(ValueError, match="bpm must be > 0"):
|
||||
main()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
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 TestSectionBuilderIntegration:
|
||||
"""Test section builder integration with SongDefinition."""
|
||||
class TestDrumloopFirstTracks:
|
||||
|
||||
def test_build_section_tracks_returns_tracks_and_sections(self):
|
||||
"""build_section_tracks returns (tracks, sections) tuple."""
|
||||
import json
|
||||
from pathlib import Path as P
|
||||
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", "Melody", "Vocals", "Clap", "Pad", "Reverb", "Delay"):
|
||||
assert name in content, f"Expected track '{name}' in output"
|
||||
|
||||
_ROOT = P(__file__).parent.parent
|
||||
from scripts.compose import build_section_tracks
|
||||
from src.selector import SampleSelector
|
||||
def test_clap_on_dembow_beats(self, tmp_path):
|
||||
from scripts.compose import build_clap_track, SECTIONS
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
genre_path = _ROOT / "knowledge" / "genres" / "reggaeton_2009.json"
|
||||
with open(genre_path, "r", encoding="utf-8") as f:
|
||||
genre_config = json.load(f)
|
||||
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
index_path = _ROOT / "data" / "sample_index.json"
|
||||
selector = SampleSelector(str(index_path))
|
||||
mock_selector = MagicMock()
|
||||
mock_selector.select.return_value = [
|
||||
MagicMock(sample={"original_path": "clap.wav"}),
|
||||
]
|
||||
|
||||
# Pass explicit sections_data since JSON now uses templates format
|
||||
sections_data = genre_config.get("structure", {}).get("templates", {}).get("extracted_real_tracks", [])
|
||||
tracks, sections = build_section_tracks(genre_config, selector, "Am", 95.0, sections_data=sections_data)
|
||||
track = build_clap_track(mock_selector, sections, offsets)
|
||||
positions = [c.position for c in track.clips]
|
||||
assert 1.0 in positions, "Clap on beat 2 (pos 1.0)"
|
||||
assert 3.5 in positions, "Clap on beat 3.5 (dembow)"
|
||||
|
||||
assert len(tracks) > 0, "Expected at least one track"
|
||||
assert len(sections) > 0, "Expected at least one section"
|
||||
# Sections should have names from the genre config
|
||||
valid_names = {"intro", "verse", "build", "pre_chorus", "chorus", "drop",
|
||||
"break", "gap", "bridge", "outro", "verse2", "chorus2", "chorus3"}
|
||||
for sec in sections:
|
||||
assert sec.name in valid_names, f"Unexpected section name: {sec.name}"
|
||||
def test_bass_uses_kick_free_zones(self):
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
def test_song_definition_has_sections_field(self):
|
||||
"""SongDefinition has a sections field."""
|
||||
from src.core.schema import SongDefinition, SongMeta, SectionDef
|
||||
analysis = _fake_analysis()
|
||||
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=[],
|
||||
sections=[SectionDef(name="intro", bars=4, energy=0.3)],
|
||||
track = build_bass_track(analysis, sections, offsets, "A", True)
|
||||
assert len(track.clips) > 0, "Bass should have clips"
|
||||
assert all(n.duration == 0.5 for n in track.clips[0].midi_notes), "Bass notes should be 0.5 beats"
|
||||
|
||||
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(analysis, 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
|
||||
|
||||
analysis = _fake_analysis()
|
||||
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_melody_track(analysis, 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 len(song.sections) == 1
|
||||
assert song.sections[0].name == "intro"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user