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 @@
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user