refactor: migrate from FL Studio to REAPER with rpp library
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)
This commit is contained in:
170
tests/test_compose_integration.py
Normal file
170
tests/test_compose_integration.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""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
|
||||
95
tests/test_converters.py
Normal file
95
tests/test_converters.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for src/composer/converters.py — rhythm_to_midi, melodic_to_midi."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
||||
from src.core.schema import MidiNote
|
||||
|
||||
|
||||
class TestRhythmToMidi:
|
||||
"""Tests for rhythm_to_midi() — channel → GM pitch mapping."""
|
||||
|
||||
def test_rhythm_to_midi_kick_channel(self):
|
||||
"""Channel 11 (kick) maps to pitch 36 with correct start/duration/velocity."""
|
||||
note_dict = {
|
||||
11: [
|
||||
{"pos": 0.0, "len": 0.25, "key": 36, "vel": 115},
|
||||
{"pos": 1.0, "len": 0.25, "key": 36, "vel": 100},
|
||||
]
|
||||
}
|
||||
result = rhythm_to_midi(note_dict)
|
||||
|
||||
assert len(result) == 2
|
||||
# Pitch is resolved from CHANNEL_PITCH, not from the dict's "key"
|
||||
assert result[0].pitch == 36
|
||||
assert result[0].start == 0.0
|
||||
assert result[0].duration == 0.25
|
||||
assert result[0].velocity == 115
|
||||
|
||||
assert result[1].pitch == 36
|
||||
assert result[1].start == 1.0
|
||||
assert result[1].duration == 0.25
|
||||
assert result[1].velocity == 100
|
||||
|
||||
def test_rhythm_to_midi_hihat_channel(self):
|
||||
"""Channel 15 (hihat) maps to pitch 42."""
|
||||
note_dict = {15: [{"pos": 0.0, "len": 0.125, "key": 42, "vel": 90}]}
|
||||
result = rhythm_to_midi(note_dict)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].pitch == 42
|
||||
assert result[0].start == 0.0
|
||||
assert result[0].duration == 0.125
|
||||
assert result[0].velocity == 90
|
||||
|
||||
def test_rhythm_to_midi_unknown_channel(self):
|
||||
"""Unknown channel (not in CHANNEL_PITCH) defaults to pitch 60."""
|
||||
note_dict = {99: [{"pos": 0.0, "len": 0.25, "key": 60, "vel": 100}]}
|
||||
result = rhythm_to_midi(note_dict)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].pitch == 60 # default fallback
|
||||
assert result[0].start == 0.0
|
||||
|
||||
def test_rhythm_to_midi_multi_channel(self):
|
||||
"""3 different channels return a flat list with all notes combined."""
|
||||
note_dict = {
|
||||
11: [{"pos": 0.0, "len": 0.25, "key": 36, "vel": 115}],
|
||||
15: [{"pos": 0.5, "len": 0.125, "key": 42, "vel": 90}],
|
||||
10: [{"pos": 1.0, "len": 0.25, "key": 39, "vel": 80}],
|
||||
}
|
||||
result = rhythm_to_midi(note_dict)
|
||||
|
||||
assert len(result) == 3
|
||||
pitches = {n.pitch for n in result}
|
||||
assert pitches == {36, 42, 39}
|
||||
|
||||
|
||||
class TestMelodicToMidi:
|
||||
"""Tests for melodic_to_midi() — key field used directly as pitch."""
|
||||
|
||||
def test_melodic_to_midi_uses_key_as_pitch(self):
|
||||
"""key=60 → pitch 60 (key field is used directly, not mapped)."""
|
||||
note_list = [
|
||||
{"pos": 0.0, "len": 0.5, "key": 60, "vel": 100},
|
||||
{"pos": 0.5, "len": 0.5, "key": 64, "vel": 90},
|
||||
{"pos": 1.0, "len": 0.5, "key": 67, "vel": 95},
|
||||
]
|
||||
result = melodic_to_midi(note_list)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].pitch == 60
|
||||
assert result[1].pitch == 64
|
||||
assert result[2].pitch == 67
|
||||
assert result[0].start == 0.0
|
||||
assert result[0].duration == 0.5
|
||||
assert result[0].velocity == 100
|
||||
|
||||
def test_melodic_to_midi_empty_list(self):
|
||||
"""Empty list returns empty list."""
|
||||
result = melodic_to_midi([])
|
||||
assert result == []
|
||||
137
tests/test_core_schema.py
Normal file
137
tests/test_core_schema.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||
|
||||
|
||||
class TestSongDefinitionInstantiation:
|
||||
"""Test SongDefinition instantiation with valid data."""
|
||||
|
||||
def test_song_definition_with_valid_data(self):
|
||||
"""SongDefinition instantiates with meta and tracks."""
|
||||
meta = SongMeta(bpm=95, key="Am", title="Test Song")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
assert song.meta.bpm == 95
|
||||
assert song.meta.key == "Am"
|
||||
assert song.meta.title == "Test Song"
|
||||
assert song.tracks == []
|
||||
|
||||
def test_song_definition_with_multiple_tracks(self):
|
||||
"""SongDefinition accepts multiple TrackDef entries."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
track1 = TrackDef(name="Kick", volume=0.85)
|
||||
track2 = TrackDef(name="Bass", volume=0.80)
|
||||
song = SongDefinition(meta=meta, tracks=[track1, track2])
|
||||
assert len(song.tracks) == 2
|
||||
assert song.tracks[0].name == "Kick"
|
||||
assert song.tracks[1].name == "Bass"
|
||||
|
||||
|
||||
class TestTrackDefWithAudioClip:
|
||||
"""Test TrackDef with audio clip (sample_path)."""
|
||||
|
||||
def test_track_with_audio_clip(self):
|
||||
"""TrackDef with audio clip has audio_path set."""
|
||||
clip = ClipDef(
|
||||
position=0.0,
|
||||
length=16.0,
|
||||
name="Kick Loop",
|
||||
audio_path="C:/samples/kick.wav",
|
||||
)
|
||||
track = TrackDef(name="Drums", clips=[clip])
|
||||
assert len(track.clips) == 1
|
||||
assert track.clips[0].is_audio
|
||||
assert track.clips[0].audio_path == "C:/samples/kick.wav"
|
||||
assert not track.clips[0].is_midi
|
||||
|
||||
def test_track_with_multiple_audio_clips(self):
|
||||
"""TrackDef can hold multiple audio clips."""
|
||||
clip1 = ClipDef(position=0.0, length=16.0, audio_path="C:/samples/kick.wav")
|
||||
clip2 = ClipDef(position=16.0, length=16.0, audio_path="C:/samples/kick2.wav")
|
||||
track = TrackDef(name="Drums", clips=[clip1, clip2])
|
||||
assert len(track.clips) == 2
|
||||
assert track.clips[0].audio_path == "C:/samples/kick.wav"
|
||||
assert track.clips[1].audio_path == "C:/samples/kick2.wav"
|
||||
|
||||
|
||||
class TestClipDefWithMidiNotes:
|
||||
"""Test ClipDef with MIDI notes."""
|
||||
|
||||
def test_clip_with_midi_notes(self):
|
||||
"""ClipDef with midi_notes is identified as MIDI clip."""
|
||||
note = MidiNote(pitch=36, start=0.0, duration=1.0, velocity=100)
|
||||
clip = ClipDef(
|
||||
position=0.0,
|
||||
length=16.0,
|
||||
name="Kick Pattern",
|
||||
midi_notes=[note],
|
||||
)
|
||||
assert clip.is_midi
|
||||
assert not clip.is_audio
|
||||
assert len(clip.midi_notes) == 1
|
||||
assert clip.midi_notes[0].pitch == 36
|
||||
|
||||
def test_clip_with_multiple_midi_notes(self):
|
||||
"""ClipDef can hold multiple MidiNote entries."""
|
||||
notes = [
|
||||
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
|
||||
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
|
||||
MidiNote(pitch=38, start=2.0, duration=0.15, velocity=100),
|
||||
]
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Drum Pattern", midi_notes=notes)
|
||||
assert len(clip.midi_notes) == 3
|
||||
assert clip.midi_notes[0].pitch == 36
|
||||
assert clip.midi_notes[1].pitch == 36
|
||||
assert clip.midi_notes[2].pitch == 38
|
||||
|
||||
|
||||
class TestValidationNegativeBPM:
|
||||
"""Test validation: negative BPM raises ValueError."""
|
||||
|
||||
def test_negative_bpm_raises_value_error(self):
|
||||
"""SongDefinition.validate() returns error for negative BPM."""
|
||||
meta = SongMeta(bpm=-10, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
errors = song.validate()
|
||||
assert any("bpm" in e.lower() for e in errors)
|
||||
|
||||
def test_zero_bpm_raises_value_error(self):
|
||||
"""SongDefinition.validate() returns error for zero BPM."""
|
||||
meta = SongMeta(bpm=0, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
errors = song.validate()
|
||||
assert any("bpm" in e.lower() for e in errors)
|
||||
|
||||
def test_valid_bpm_passes(self):
|
||||
"""SongDefinition.validate() passes for BPM 20-999."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
errors = song.validate()
|
||||
assert not any("bpm" in e.lower() for e in errors)
|
||||
|
||||
|
||||
class TestMidiNote:
|
||||
"""Test MidiNote dataclass."""
|
||||
|
||||
def test_midi_note_defaults(self):
|
||||
"""MidiNote has sensible defaults for velocity."""
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0)
|
||||
assert note.pitch == 60
|
||||
assert note.start == 0.0
|
||||
assert note.duration == 1.0
|
||||
assert note.velocity == 64 # default
|
||||
|
||||
def test_midi_note_explicit_velocity(self):
|
||||
"""MidiNote accepts explicit velocity."""
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=127)
|
||||
assert note.velocity == 127
|
||||
|
||||
def test_midi_note_velocity_clamping(self):
|
||||
"""MidiNote does NOT clamp — accepts any int (caller's responsibility)."""
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200)
|
||||
assert note.velocity == 200
|
||||
176
tests/test_reaper_builder.py
Normal file
176
tests/test_reaper_builder.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Tests for src/reaper_builder/ — RPPBuilder, rpp_writer."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||
from src.reaper_builder import RPPBuilder
|
||||
|
||||
|
||||
class TestRPPBuilderWrite:
|
||||
"""Test RPPBuilder.write() produces valid .rpp output."""
|
||||
|
||||
def test_write_produces_reaper_project_marker(self):
|
||||
"""RPPBuilder.write() produces a file containing 'REAPER_PROJECT'."""
|
||||
meta = SongMeta(bpm=95, key="Am", title="Test")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
assert "REAPER_PROJECT" in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_write_produces_tempo_line(self):
|
||||
"""Output contains TEMPO line with correct BPM."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
assert "TEMPO 95 " in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestRPPBuilderAudioTrack:
|
||||
"""Test audio track generates SOURCE WAVE block with correct file path."""
|
||||
|
||||
def test_audio_track_generates_source_wave_block(self):
|
||||
"""Audio clip produces <SOURCE WAVE> block with FILE path."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
clip = ClipDef(
|
||||
position=0.0,
|
||||
length=16.0,
|
||||
name="Kick Loop",
|
||||
audio_path="C:/samples/kick.wav",
|
||||
)
|
||||
track = TrackDef(name="Drums", clips=[clip])
|
||||
song = SongDefinition(meta=meta, tracks=[track])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
assert "<SOURCE WAVE\n" in content
|
||||
assert 'FILE C:/samples/kick.wav' in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_audio_track_includes_track_name(self):
|
||||
"""Audio track block contains the track NAME."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
clip = ClipDef(position=0.0, length=16.0, audio_path="C:/kick.wav")
|
||||
track = TrackDef(name="Kick", clips=[clip])
|
||||
song = SongDefinition(meta=meta, tracks=[track])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
assert "NAME Kick" in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestRPPBuilderMidiTrack:
|
||||
"""Test MIDI track generates MIDI event lines (E lines)."""
|
||||
|
||||
def test_midi_track_generates_e_lines(self):
|
||||
"""MIDI clip produces E event lines in the output."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
note = MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115)
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=[note])
|
||||
track = TrackDef(name="Drums", clips=[clip])
|
||||
song = SongDefinition(meta=meta, tracks=[track])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
assert "<SOURCE MIDI\n" in content
|
||||
assert "E " in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_midi_track_note_on_off_pairs(self):
|
||||
"""MIDI note produces note-on (90) and note-off (80) E lines."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
notes = [
|
||||
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
|
||||
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
|
||||
]
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=notes)
|
||||
track = TrackDef(name="Drums", clips=[clip])
|
||||
song = SongDefinition(meta=meta, tracks=[track])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
# Note on: status 90, pitch, velocity
|
||||
assert "90" in content
|
||||
# Note off: status 80, pitch, 00
|
||||
assert "80" in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestRPPBuilderMasterTrack:
|
||||
"""Test that RPPBuilder includes a master track."""
|
||||
|
||||
def test_output_contains_master_track(self):
|
||||
"""Generated .rpp contains a master track."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[])
|
||||
builder = RPPBuilder(song)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
builder.write(tmp_path)
|
||||
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||
assert "NAME master" in content
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
105
tests/test_render.py
Normal file
105
tests/test_render.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for src/reaper_builder/render.py — render_project."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.reaper_builder.render import render_project
|
||||
|
||||
|
||||
class TestRenderProjectFileNotFound:
|
||||
"""Test render_project raises FileNotFoundError when reaper.exe path doesn't exist."""
|
||||
|
||||
def test_render_project_raises_file_not_found_for_nonexistent_reaper_exe(self):
|
||||
"""FileNotFoundError is raised when reaper.exe does not exist."""
|
||||
nonexistent = Path("C:/Program Files/NONEXISTENT_REAPER/reaper.exe")
|
||||
if nonexistent.exists():
|
||||
pytest.skip("Nonexistent path actually exists — cannot test this")
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
render_project(
|
||||
rpp_path="input.rpp",
|
||||
output_wav="output.wav",
|
||||
reaper_exe=nonexistent,
|
||||
)
|
||||
|
||||
def test_render_project_raises_file_not_found_default_path(self):
|
||||
"""FileNotFoundError raised when default reaper.exe doesn't exist and none provided."""
|
||||
# Patch DEFAULT_REAPER_EXE to a definitely-nonexistent path
|
||||
with patch("src.reaper_builder.render.DEFAULT_REAPER_EXE", Path("/no/such/reaper.exe")):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
render_project(
|
||||
rpp_path="input.rpp",
|
||||
output_wav="output.wav",
|
||||
reaper_exe=None,
|
||||
)
|
||||
|
||||
|
||||
class TestRenderProjectSubprocessError:
|
||||
"""Test render_project raises RuntimeError when subprocess returns non-zero exit code."""
|
||||
|
||||
def test_render_project_raises_runtime_error_on_nonzero_exit(self):
|
||||
"""RuntimeError is raised when subprocess.run returns non-zero returncode."""
|
||||
from src.reaper_builder.render import DEFAULT_REAPER_EXE
|
||||
|
||||
# Check if reaper exists — if not, skip
|
||||
if not DEFAULT_REAPER_EXE.exists():
|
||||
pytest.skip(f"REAPER not installed at {DEFAULT_REAPER_EXE}")
|
||||
|
||||
# Create a minimal valid .rpp for this test
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".rpp", delete=False, encoding="utf-8") as f:
|
||||
rpp_path = f.name
|
||||
f.write('<REAPER_PROJECT 0.1 "6.0" 0\n>\n')
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
wav_path = f.name
|
||||
|
||||
try:
|
||||
# Mock subprocess.run to return non-zero
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "Test error"
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
render_project(rpp_path=rpp_path, output_wav=wav_path)
|
||||
assert "1" in str(exc_info.value) or "failed" in str(exc_info.value).lower()
|
||||
finally:
|
||||
Path(rpp_path).unlink(missing_ok=True)
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
|
||||
def test_render_project_raises_runtime_error_with_error_message(self):
|
||||
"""RuntimeError output includes stdout/stderr from REAPER failure."""
|
||||
from src.reaper_builder.render import DEFAULT_REAPER_EXE
|
||||
|
||||
if not DEFAULT_REAPER_EXE.exists():
|
||||
pytest.skip(f"REAPER not installed at {DEFAULT_REAPER_EXE}")
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".rpp", delete=False, encoding="utf-8") as f:
|
||||
rpp_path = f.name
|
||||
f.write('<REAPER_PROJECT 0.1 "6.0" 0\n>\n')
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
wav_path = f.name
|
||||
|
||||
try:
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 2
|
||||
mock_result.stdout = "standard output text"
|
||||
mock_result.stderr = "error output text"
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
render_project(rpp_path=rpp_path, output_wav=wav_path)
|
||||
# Error message should include the exit code or stderr
|
||||
err_str = str(exc_info.value)
|
||||
assert "2" in err_str or "error output text" in err_str
|
||||
finally:
|
||||
Path(rpp_path).unlink(missing_ok=True)
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
127
tests/test_render_cli.py
Normal file
127
tests/test_render_cli.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Tests for scripts/compose.py render CLI flags."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
import argparse
|
||||
from unittest.mock import patch, MagicMock
|
||||
from scripts.compose import main as compose_main
|
||||
|
||||
|
||||
class TestRenderFlag:
|
||||
"""Test --render and --render-output CLI arguments."""
|
||||
|
||||
def test_render_flag_defaults_to_false(self):
|
||||
"""Without --render, the render flag should be False."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--render", action="store_true")
|
||||
parser.add_argument("--render-output", default=None)
|
||||
|
||||
args = parser.parse_args([])
|
||||
assert args.render is False
|
||||
|
||||
def test_render_flag_true_when_provided(self):
|
||||
"""With --render, the flag should be True."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--render", action="store_true")
|
||||
parser.add_argument("--render-output", default=None)
|
||||
|
||||
args = parser.parse_args(["--render"])
|
||||
assert args.render is True
|
||||
|
||||
def test_render_output_defaults_to_none(self):
|
||||
"""--render-output defaults to None when not provided."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--render", action="store_true")
|
||||
parser.add_argument("--render-output", default=None)
|
||||
|
||||
args = parser.parse_args([])
|
||||
assert args.render_output is None
|
||||
|
||||
@patch("scripts.compose.render_project")
|
||||
@patch("scripts.compose.RPPBuilder")
|
||||
def test_render_triggers_render_project_call(self, mock_builder_cls, mock_render):
|
||||
"""Calling main with --render invokes render_project."""
|
||||
mock_builder = MagicMock()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||
mock_sel_instance = MagicMock()
|
||||
mock_sel_instance.select_one.return_value = None
|
||||
mock_selector.return_value = mock_sel_instance
|
||||
|
||||
with patch("sys.argv", ["compose.py", "--genre", "reggaeton", "--render"]):
|
||||
compose_main()
|
||||
|
||||
mock_render.assert_called_once()
|
||||
call_args = mock_render.call_args
|
||||
# First arg should be the .rpp path, second should be .wav path
|
||||
rpp_path = call_args[0][0]
|
||||
wav_path = call_args[0][1]
|
||||
assert rpp_path.endswith(".rpp")
|
||||
assert wav_path.endswith(".wav")
|
||||
|
||||
@patch("scripts.compose.render_project")
|
||||
@patch("scripts.compose.RPPBuilder")
|
||||
def test_without_render_does_not_call_render_project(self, mock_builder_cls, mock_render):
|
||||
"""Calling main without --render does NOT invoke render_project."""
|
||||
mock_builder = MagicMock()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||
mock_sel_instance = MagicMock()
|
||||
mock_sel_instance.select_one.return_value = None
|
||||
mock_selector.return_value = mock_sel_instance
|
||||
|
||||
with patch("sys.argv", ["compose.py", "--genre", "reggaeton"]):
|
||||
compose_main()
|
||||
|
||||
mock_render.assert_not_called()
|
||||
|
||||
@patch("scripts.compose.render_project")
|
||||
@patch("scripts.compose.RPPBuilder")
|
||||
def test_render_output_overrides_default_wav_path(self, mock_builder_cls, mock_render):
|
||||
"""--render-output sets the WAV path explicitly."""
|
||||
mock_builder = MagicMock()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||
mock_sel_instance = MagicMock()
|
||||
mock_sel_instance.select_one.return_value = None
|
||||
mock_selector.return_value = mock_sel_instance
|
||||
|
||||
with patch("sys.argv", [
|
||||
"compose.py",
|
||||
"--genre", "reggaeton",
|
||||
"--render",
|
||||
"--render-output", "output/my_render.wav"
|
||||
]):
|
||||
compose_main()
|
||||
|
||||
mock_render.assert_called_once()
|
||||
wav_path = mock_render.call_args[0][1]
|
||||
assert wav_path == "output/my_render.wav"
|
||||
|
||||
@patch("scripts.compose.render_project")
|
||||
@patch("scripts.compose.RPPBuilder")
|
||||
def test_render_project_propagates_file_not_found_error(self, mock_builder_cls, mock_render):
|
||||
"""FileNotFoundError from render_project is propagated to caller."""
|
||||
mock_builder = MagicMock()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
mock_render.side_effect = FileNotFoundError("reaper.exe not found")
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||
mock_sel_instance = MagicMock()
|
||||
mock_sel_instance.select_one.return_value = None
|
||||
mock_selector.return_value = mock_sel_instance
|
||||
|
||||
with patch("sys.argv", [
|
||||
"compose.py",
|
||||
"--genre", "reggaeton",
|
||||
"--render",
|
||||
]):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
compose_main()
|
||||
Reference in New Issue
Block a user