Replace FL Studio binary .flp output with REAPER text-based .rpp output using the rpp Python library (Perlence/rpp). - Add core/schema.py: DAW-agnostic data types (SongDefinition, TrackDef, ClipDef, MidiNote, PluginDef) - Add reaper_builder/: RPP file generation via rpp.Element + headless render via reaper.exe CLI - Add composer/converters.py: bridge rhythm.py/melodic.py note dicts to core.schema MidiNote objects - Rewrite scripts/compose.py: real generator pipeline with --render flag - Delete src/flp_builder/, src/scanner/, mcp/, flstudio-mcp/, old scripts - Add 40 passing tests (schema, builder, converters, compose, render)
171 lines
5.8 KiB
Python
171 lines
5.8 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.
|
|
"""
|
|
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_bar_map = {"reggaeton": 64, "trap": 32, "house": 64, "drill": 32}
|
|
bar_count = genre_bar_map.get(genre.lower(), 48)
|
|
|
|
# Drum tracks
|
|
drum_tracks = []
|
|
for role, generator_name in [
|
|
("kick", "kick_main_notes"),
|
|
("snare", "snare_verse_notes"),
|
|
("hihat", "hihat_16th_notes"),
|
|
("perc", "perc_combo_notes"),
|
|
]:
|
|
note_dict = get_notes(generator_name, bar_count)
|
|
midi_notes = rhythm_to_midi(note_dict)
|
|
clip = ClipDef(
|
|
position=0.0,
|
|
length=bar_count * 4.0,
|
|
name=f"{role.capitalize()} Pattern",
|
|
midi_notes=midi_notes,
|
|
)
|
|
drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip]))
|
|
|
|
# Melodic tracks (no selector — audio_path stays None)
|
|
for role, generator_fn in [
|
|
("bass", bass_tresillo),
|
|
("lead", lead_hook),
|
|
("chords", chords_block),
|
|
("pad", pad_sustain),
|
|
]:
|
|
note_list = generator_fn(key=key, bars=bar_count)
|
|
midi_notes = melodic_to_midi(note_list)
|
|
clip = ClipDef(
|
|
position=0.0,
|
|
length=bar_count * 4.0,
|
|
name=f"{role.capitalize()} MIDI",
|
|
midi_notes=midi_notes,
|
|
)
|
|
drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip]))
|
|
|
|
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
|
|
return SongDefinition(meta=meta, tracks=drum_tracks)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_4_tracks(self, tmp_path):
|
|
"""The .rpp output contains at least 4 <TRACK blocks."""
|
|
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")
|
|
assert track_count >= 4, f"Expected >= 4 tracks, got {track_count}"
|
|
|
|
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
|