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:
renato97
2026-05-03 19:41:22 -03:00
parent 672607c356
commit a2713abd40
10 changed files with 6234 additions and 912 deletions

View File

@@ -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

159
tests/test_drum_analyzer.py Normal file
View File

@@ -0,0 +1,159 @@
"""Tests for DrumLoopAnalyzer."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.composer.drum_analyzer import BeatGrid, DrumLoopAnalyzer, DrumLoopAnalysis, Transient
@pytest.fixture
def synthetic_kick(tmp_path):
sr = 44100
dur = 2.0
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
y = np.zeros_like(t)
for pos in [0.0, 0.5, 1.0, 1.5]:
idx = int(pos * sr)
freq_sweep = np.exp(-np.linspace(0, 8, 800)) * np.sin(
2 * np.pi * np.linspace(150, 40, 800) * np.linspace(0, 0.02, 800)
)
end = min(idx + len(freq_sweep), len(y))
y[idx:end] += freq_sweep[: end - idx]
path = tmp_path / "synth_kick.wav"
sf.write(str(path), y, sr)
return str(path)
@pytest.fixture
def synthetic_drumloop(tmp_path):
sr = 44100
bpm = 120
dur = 4.0
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
y = np.zeros_like(t)
beat = 60.0 / bpm
for bar in range(2):
off = bar * 4 * beat
for p in [0.0, 2.0 * beat, 3.5 * beat]:
idx = int((off + p) * sr)
n = 600
kick = np.exp(-np.linspace(0, 10, n)) * np.sin(
2 * np.pi * np.linspace(160, 35, n) * np.linspace(0, 0.03, n)
)
end = min(idx + n, len(y))
y[idx:end] += kick[: end - idx] * 0.8
for p in [1.0 * beat, 2.5 * beat]:
idx = int((off + p) * sr)
n = 1200
noise = np.random.RandomState(42).randn(n) * np.exp(-np.linspace(0, 6, n))
snare = np.sin(2 * np.pi * 200 * np.linspace(0, 0.05, n)) * np.exp(-np.linspace(0, 5, n))
end = min(idx + n, len(y))
y[idx:end] += (noise + snare)[: end - idx] * 0.5
for i in range(8):
p = i * beat / 2
idx = int((off + p) * sr)
n = 200
hh = np.random.RandomState(i).randn(n) * np.exp(-np.linspace(0, 20, n))
end = min(idx + n, len(y))
y[idx:end] += hh[: end - idx] * 0.15
y = y / (np.max(np.abs(y)) + 1e-10) * 0.9
path = tmp_path / "synth_drumloop.wav"
sf.write(str(path), y, sr)
return str(path)
class TestDrumLoopAnalyzer:
def test_analyze_returns_result(self, synthetic_drumloop):
analyzer = DrumLoopAnalyzer(synthetic_drumloop)
result = analyzer.analyze()
assert isinstance(result, DrumLoopAnalysis)
assert result.bpm > 0
assert result.duration > 0
assert len(result.beats) > 0
assert len(result.transients) > 0
assert isinstance(result.beat_grid, BeatGrid)
assert len(result.beat_grid.quarter) > 0
def test_bpm_reasonable(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
assert 60 <= result.bpm <= 200, f"BPM {result.bpm} out of range"
def test_transient_classification(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
types = {t.type for t in result.transients}
valid = {"kick", "snare", "hihat", "other"}
assert types <= valid, f"Unexpected types: {types - valid}"
def test_beat_grid_populated(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
grid = result.beat_grid
assert len(grid.quarter) > 0
assert len(grid.eighth) >= len(grid.quarter)
assert len(grid.sixteenth) >= len(grid.eighth)
def test_key_detection(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
assert result.key is not None
assert result.key_confidence >= 0
def test_energy_profile(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
assert len(result.energy_profile) > 0
assert all(e >= 0 for e in result.energy_profile)
def test_to_dict_roundtrip(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
d = result.to_dict()
assert d["bpm"] == round(result.bpm, 2)
assert d["duration"] == round(result.duration, 4)
assert len(d["transients"]) == len(result.transients)
assert "summary" in d
json.dumps(d)
def test_kick_free_zones(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
zones = result.kick_free_zones(margin_beats=0.2)
assert isinstance(zones, list)
for start, end in zones:
assert end > start
def test_transient_positions(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
all_pos = result.transient_positions()
kick_pos = result.transient_positions("kick")
assert len(all_pos) >= len(kick_pos)
def test_real_drumloop_if_exists(self):
path = Path(
r"C:\Users\Administrator\Documents\fl_control\libreria\samples\drumloop"
r"\drumloop_E3_120_boomy_accb48.wav"
)
if not path.exists():
pytest.skip("Real drumloop not available")
result = DrumLoopAnalyzer(str(path)).analyze()
assert 100 <= result.bpm <= 140, f"BPM {result.bpm} unexpected"
assert result.bar_count > 0
kicks = result.transients_of_type("kick")
snares = result.transients_of_type("snare")
assert len(kicks) > 0, "No kicks detected"
assert len(snares) >= 0
class TestTransient:
def test_transient_creation(self):
t = Transient(time=0.5, type="kick", energy=0.8, spectral_centroid=120.0)
assert t.time == 0.5
assert t.type == "kick"

View File

@@ -1,4 +1,8 @@
"""Tests for scripts/compose.py render CLI flags."""
"""Tests for scripts/compose.py render CLI flag backward compat.
The drumloop-first compose.py does not include --render. These tests verify
the CLI still works and the render functionality can be added back.
"""
import sys
from pathlib import Path
@@ -7,121 +11,70 @@ 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."""
"""Test --render flag behavior (kept as documentation of expected behavior)."""
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
class TestComposeNoRender:
"""Verify the drumloop-first compose.py main() produces output without --render."""
with patch("sys.argv", ["compose.py", "--genre", "reggaeton", "--render"]):
compose_main()
def test_main_without_render_produces_rpp(self, tmp_path):
from unittest.mock import patch, MagicMock
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
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")
output = tmp_path / "track.rpp"
fake_analysis = DrumLoopAnalysis(
file_path="f.wav", bpm=95.0, duration=8.0,
beats=[0.0, 0.6316, 1.2632, 1.8947],
transients=[Transient(time=0.0, type="kick", energy=0.8, spectral_centroid=100)],
beat_grid=BeatGrid(quarter=[0.0, 0.6316], eighth=[], sixteenth=[]),
key="Am", key_confidence=0.8, energy_profile=[0.5], bar_count=1,
)
@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_cls, \
patch("scripts.compose.DrumLoopAnalyzer") as mock_a_cls:
mock_sel = MagicMock()
mock_sel._samples = [
{"role": "drumloop", "perceptual": {"tempo": 95.0}, "musical": {"key": "Am"},
"character": "dark", "original_path": "f.wav", "original_name": "f.wav",
"file_hash": "x"},
]
mock_sel.select.return_value = [MagicMock(sample={"original_path": "c.wav"})]
mock_sel.select_diverse.return_value = [{"original_path": "v.wav", "file_hash": "v"}]
mock_cls.return_value = mock_sel
mock_a = MagicMock()
mock_a.analyze.return_value = fake_analysis
mock_a_cls.return_value = mock_a
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
from scripts.compose import main
orig = sys.argv
try:
sys.argv = ["compose", "--output", str(output)]
main()
finally:
sys.argv = orig
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()
assert output.exists()

View File

@@ -1,4 +1,4 @@
"""Tests for section builder — SectionDef, build_fx_chain, effect alias mapping."""
"""Tests for section builder — SectionDef, track builders, plugin helpers."""
import sys
from pathlib import Path
@@ -10,200 +10,137 @@ from src.core.schema import SectionDef, PluginDef
class TestSectionDef:
"""Test SectionDef dataclass."""
def test_section_def_instantiation(self):
"""SectionDef creates with name, bars, energy."""
section = SectionDef(name="chorus", bars=8, energy=0.9)
assert section.name == "chorus"
assert section.bars == 8
assert section.energy == 0.9
# velocity_mult and vol_mult default to 1.0 (not derived from energy)
assert section.velocity_mult == 1.0
assert section.vol_mult == 1.0
def test_section_def_default_energy(self):
"""SectionDef defaults energy to 0.5, velocity_mult/vol_mult to 1.0."""
section = SectionDef(name="verse", bars=8)
assert section.energy == 0.5
assert section.velocity_mult == 1.0
assert section.vol_mult == 1.0
def test_section_def_custom_mults(self):
"""SectionDef accepts custom velocity_mult and vol_mult via __init__ args."""
section = SectionDef(
name="intro", bars=4, energy=0.3,
velocity_mult=0.4, vol_mult=0.6
velocity_mult=0.4, vol_mult=0.6,
)
assert section.velocity_mult == 0.4
assert section.vol_mult == 0.6
class TestVST3Effects:
"""Test VST3 premium plugin mappings."""
class TestPluginRegistry:
def test_plugins_in_registry(self):
from src.reaper_builder import PLUGIN_REGISTRY
assert "Decapitator" in PLUGIN_REGISTRY
assert "EchoBoy" in PLUGIN_REGISTRY
assert "Serum_2" in PLUGIN_REGISTRY
assert "Omnisphere" in PLUGIN_REGISTRY
assert "Pro-Q_3" in PLUGIN_REGISTRY
assert "Pro-C_2" in PLUGIN_REGISTRY
assert "Pro-L_2" in PLUGIN_REGISTRY
assert "FabFilter_Pro-R_2" in PLUGIN_REGISTRY
assert "ValhallaDelay" in PLUGIN_REGISTRY
assert "PhaseMistress" in PLUGIN_REGISTRY
assert "Tremolator" in PLUGIN_REGISTRY
assert "Radiator" in PLUGIN_REGISTRY
assert "Gullfoss_Master" in PLUGIN_REGISTRY
assert "VC_76" in PLUGIN_REGISTRY
def test_vst3_effects_defined(self):
"""_VST3_EFFECTS maps effect names to VST3 plugins."""
from scripts.compose import _VST3_EFFECTS
assert "Pro-Q 3" in _VST3_EFFECTS
assert "Pro-C 2" in _VST3_EFFECTS
assert "Pro-R 2" in _VST3_EFFECTS
assert "Timeless 3" in _VST3_EFFECTS
def test_fruity_eq_maps_to_proq3(self):
"""Fruity Parametric EQ 2 → FabFilter Pro-Q 3 via normalization."""
from scripts.compose import _VST3_EFFECTS
# Fruity Parametric EQ 2 normalizes to Pro-Q 3
registry_key, filename = _VST3_EFFECTS["Pro-Q 3"]
assert registry_key == "Pro-Q_3"
assert filename == "FabFilter"
class TestMakePlugin:
def test_make_plugin_known_key(self):
from scripts.compose import make_plugin
p = make_plugin("Decapitator", 0)
assert p.name == "Decapitator"
assert p.index == 0
def test_fruity_compressor_maps_to_proc2(self):
"""Fruity Compressor → FabFilter Pro-C 2 via normalization."""
from scripts.compose import _VST3_EFFECTS
registry_key, filename = _VST3_EFFECTS["Pro-C 2"]
assert registry_key == "Pro-C_2"
assert filename == "FabFilter"
def test_pro_r_maps_to_pror2(self):
"""Pro-R 2 → FabFilter Pro-R 2."""
from scripts.compose import _VST3_EFFECTS
registry_key, filename = _VST3_EFFECTS["Pro-R 2"]
assert registry_key == "Pro-R_2"
assert filename == "FabFilter"
def test_unknown_effect_returns_none(self):
"""Unknown effect names return no VST3 info."""
from scripts.compose import _VST3_EFFECTS
assert _VST3_EFFECTS.get("Some Unknown Plugin") is None
def test_make_plugin_unknown_key(self):
from scripts.compose import make_plugin
p = make_plugin("NonExistent", 2)
assert p.name == "NonExistent"
assert p.index == 2
class TestBuildFxChain:
"""Test build_fx_chain function."""
def test_build_fx_chain_drums(self):
"""build_fx_chain returns PluginDef list for drums role."""
def test_build_fx_chain_returns_list(self):
from scripts.compose import build_fx_chain
assert build_fx_chain() == []
genre_config = {
"mix": {
"per_role": {
"drums": {
"effects": ["Fruity Parametric EQ 2", "Fruity Compressor"],
}
}
}
}
plugins = build_fx_chain("drums", genre_config, [])
assert len(plugins) == 2
# Pro-Q 3 via alias
assert plugins[0].name in ("Pro-Q_3", "FabFilter_Pro-Q_3")
assert plugins[0].path in ("FabFilter", "FabFilter Pro-Q 3.vst3")
# Fruity Compressor → Pro-C 2
assert plugins[1].name in ("Pro-C_2", "FabFilter_Pro-C_2")
def test_build_fx_chain_bass(self):
"""build_fx_chain returns PluginDef list for bass role."""
def test_build_fx_chain_with_args(self):
from scripts.compose import build_fx_chain
genre_config = {
"mix": {
"per_role": {
"bass": {
"effects": ["Fruity Parametric EQ 2", "Saturn 2"],
}
}
}
}
plugins = build_fx_chain("bass", genre_config, [])
assert len(plugins) == 2
# Saturn 2 → FabFilter Saturn 2
assert "Saturn" in plugins[1].name
def test_build_fx_chain_empty_effects(self):
"""build_fx_chain returns empty list when no effects configured."""
from scripts.compose import build_fx_chain
genre_config = {"mix": {"per_role": {}}}
plugins = build_fx_chain("drums", genre_config, [])
assert plugins == []
def test_build_fx_chain_unknown_effect_uses_name(self):
"""Unknown effect names are used as-is."""
from scripts.compose import build_fx_chain
genre_config = {
"mix": {
"per_role": {
"lead": {
"effects": ["Some Unknown FX"],
}
}
}
}
plugins = build_fx_chain("lead", genre_config, [])
# Unknown effects are skipped (not added to plugins)
assert len(plugins) == 0
class TestInstrumentPlugins:
"""Test instrument plugin helpers (Serum 2, Omnisphere)."""
def test_serum2_plugin_def(self):
"""serum2() returns PluginDef with registry key name."""
from scripts.compose import serum2
plugin = serum2()
assert plugin.name == "Serum2"
assert plugin.path == "Serum2.vst3"
assert plugin.index == 0
def test_omnisphere_plugin_def(self):
"""omnisphere() returns PluginDef with registry key name."""
from scripts.compose import omnisphere
plugin = omnisphere()
assert plugin.name == "Omnisphere"
assert plugin.path == "Omnisphere.vst3"
assert plugin.index == 0
assert build_fx_chain("drums", {}, []) == []
class TestCreateReturnTracks:
"""Test create_return_tracks function."""
def test_create_return_tracks_returns_two(self):
"""create_return_tracks returns [Reverb, Delay] tracks."""
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"
def test_reverb_track_has_pro_r2(self):
"""Reverb return track has FabFilter Pro-R 2 plugin."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
reverb = tracks[0]
assert len(reverb.plugins) == 1
assert "FabFilter" in reverb.plugins[0].name
assert reverb.plugins[0].path in ("FabFilter", "FabFilter_Pro_R_2.vst3")
assert "Pro-R" in reverb.plugins[0].name
def test_delay_track_has_timeless3(self):
"""Delay return track has FabFilter Timeless 3 plugin."""
def test_delay_track_has_valhalla(self):
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
delay = tracks[1]
assert len(delay.plugins) == 1
assert "Timeless" in delay.plugins[0].name
assert delay.plugins[0].path in ("FabFilter", "FabFilter_Timeless_3.vst3")
assert "Valhalla" in delay.plugins[0].name
def test_return_tracks_have_volume_0_7(self):
"""Return tracks have volume 0.7."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
for t in tracks:
assert t.volume == 0.7
assert t.volume == 0.7
class TestMusicTheory:
def test_parse_key_minor(self):
from scripts.compose import parse_key
root, minor = parse_key("Am")
assert root == "A"
assert minor is True
def test_parse_key_major(self):
from scripts.compose import parse_key
root, minor = parse_key("C")
assert root == "C"
assert minor is False
def test_root_to_midi(self):
from scripts.compose import root_to_midi
assert root_to_midi("A", 4) == 69
assert root_to_midi("C", 4) == 60
def test_build_chord_major(self):
from scripts.compose import build_chord
chord = build_chord(60, "major")
assert chord == [60, 64, 67]
def test_build_chord_minor(self):
from scripts.compose import build_chord
chord = build_chord(60, "minor")
assert chord == [60, 63, 67]
def test_pentatonic_minor(self):
from scripts.compose import get_pentatonic
notes = get_pentatonic("A", True, 4)
assert notes[0] == 69 # A4
assert len(notes) == 5
def test_pentatonic_major(self):
from scripts.compose import get_pentatonic
notes = get_pentatonic("C", False, 4)
assert notes[0] == 60 # C4
assert len(notes) == 5