- Reverse-engineer drum patterns from 2 real reggaeton tracks with librosa - Create patterns.py with extracted frequency data (kick/snare/hihat positions) - Rewrite rhythm.py with pattern-bank generators (dembow, dense, trapico, offbeat) - Rewrite melodic.py with section-aware generators and humanization - Add weighted random sample selection in SampleSelector (top-5 pool) - Add generate_structure() with randomized templates and energy variance - Fix RPP structure: TEMPO arity (3→4 args), string quoting for empty strings - Rewrite quick_drumloop_test.py with correct REAPER ground truth format - Add scripts/analyze_examples.py for reverse engineering audio tracks - Add --seed argument for reproducible generation - 72 tests passing
226 lines
8.1 KiB
Python
226 lines
8.1 KiB
Python
"""Integration tests for scripts/compose.py — end-to-end 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
)
|
|
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()
|
|
|
|
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
|
|
return SongDefinition(meta=meta, tracks=tracks + return_tracks, sections=sections)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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_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
|
|
|
|
|
|
class TestSectionBuilderIntegration:
|
|
"""Test section builder integration with SongDefinition."""
|
|
|
|
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
|
|
|
|
_ROOT = P(__file__).parent.parent
|
|
from scripts.compose import build_section_tracks
|
|
from src.selector import SampleSelector
|
|
|
|
genre_path = _ROOT / "knowledge" / "genres" / "reggaeton_2009.json"
|
|
with open(genre_path, "r", encoding="utf-8") as f:
|
|
genre_config = json.load(f)
|
|
|
|
index_path = _ROOT / "data" / "sample_index.json"
|
|
selector = SampleSelector(str(index_path))
|
|
|
|
# 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)
|
|
|
|
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_song_definition_has_sections_field(self):
|
|
"""SongDefinition has a sections field."""
|
|
from src.core.schema import SongDefinition, SongMeta, SectionDef
|
|
|
|
meta = SongMeta(bpm=95, key="Am")
|
|
song = SongDefinition(
|
|
meta=meta,
|
|
tracks=[],
|
|
sections=[SectionDef(name="intro", bars=4, energy=0.3)],
|
|
)
|
|
assert len(song.sections) == 1
|
|
assert song.sections[0].name == "intro" |