feat: professional reggaeton production engine — 7 SDD changes, 302 tests
- section-energy: track activity matrix + volume/velocity multipliers per section - smart-chords: ChordEngine with voice leading, inversions, 4 emotion modes - hook-melody: melody engine with hook/stabs/smooth styles, call-and-response - mix-calibration: Calibrator module (LUFS volumes, HPF/LPF, stereo, sends, master) - transitions-fx: FX track with risers/impacts/sweeps at section boundaries - sidechain: MIDI CC11 bass ducking on kick hits via DrumLoopAnalyzer - presets-pack: role-aware plugin presets (Serum/Decapitator/Omnisphere per role) Full SDD pipeline (propose→spec→design→tasks→apply→verify) for all 7 changes. 302/302 tests passing.
This commit is contained in:
@@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef, CCEvent
|
||||
from src.reaper_builder import RPPBuilder
|
||||
|
||||
|
||||
@@ -445,9 +445,9 @@ class TestVST3PresetData:
|
||||
# Check that plugins WITH preset data have that data in output
|
||||
from src.reaper_builder import PLUGIN_PRESETS, VST3_REGISTRY
|
||||
vst3_keys = set(VST3_REGISTRY.keys())
|
||||
for name, preset_lines in PLUGIN_PRESETS.items():
|
||||
# Only check VST3 plugins (skip VST2 plugins which are in the same dict now)
|
||||
if name not in vst3_keys:
|
||||
for (name, role), preset_lines in PLUGIN_PRESETS.items():
|
||||
# Only check VST3 plugins and default role (backward compat)
|
||||
if name not in vst3_keys or role != "":
|
||||
continue
|
||||
if len(preset_lines) > 0:
|
||||
# Check first preset line — most distinctive, no collision risk
|
||||
@@ -455,3 +455,168 @@ class TestVST3PresetData:
|
||||
assert first_line in content, f"{name} preset line not found in output"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestDVolEmission:
|
||||
"""Test D_VOL emission for audio clips with vol_mult."""
|
||||
|
||||
def test_audio_clip_vol_mult_not_one_emits_dvol(self):
|
||||
"""Audio clip with vol_mult=0.7 emits D_VOL in ITEM."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
clip = ClipDef(
|
||||
position=0.0, length=16.0, name="Test",
|
||||
audio_path="C:/test.wav", vol_mult=0.7,
|
||||
)
|
||||
track = TrackDef(name="Test Track", 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 "D_VOL 0.7" in content, "D_VOL line expected for vol_mult=0.7"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_audio_clip_default_vol_mult_emits_no_dvol(self):
|
||||
"""Audio clip with default vol_mult=1.0 emits NO D_VOL."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
clip = ClipDef(
|
||||
position=0.0, length=16.0, name="Test",
|
||||
audio_path="C:/test.wav", # default vol_mult=1.0
|
||||
)
|
||||
track = TrackDef(name="Test Track", 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 "D_VOL" not in content, "No D_VOL expected for default vol_mult"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_midi_clip_vol_mult_scales_velocity(self):
|
||||
"""MIDI clip vol_mult scales velocity in output E lines."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100)
|
||||
clip = ClipDef(
|
||||
position=0.0, length=16.0, name="Test MIDI",
|
||||
midi_notes=[note], vol_mult=0.5,
|
||||
)
|
||||
track = TrackDef(name="MIDI Track", 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")
|
||||
# Velocity should be 100 * 0.5 = 50 = 0x32
|
||||
# The E line format: E <delta> 90 <pitch_hex> <vel_hex>
|
||||
# pitch 60 = 0x3c, velocity 50 = 0x32
|
||||
assert "90 3c 32" in content, f"Expected velocity 50 (0x32) in E line, got content fragment"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestCCEmission:
|
||||
"""Test that CC11 events are emitted as B0 0B E-lines."""
|
||||
|
||||
def test_midi_source_with_cc_events(self):
|
||||
"""_build_midi_source emits B0 0B lines for clips with midi_cc."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100)
|
||||
cc = CCEvent(controller=11, time=0.0, value=50)
|
||||
clip = ClipDef(
|
||||
position=0.0, length=16.0, name="Test",
|
||||
midi_notes=[note], midi_cc=[cc],
|
||||
)
|
||||
track = TrackDef(name="MIDI CC", 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")
|
||||
# CC event at time 0, controller 11, value 50 = 0x32
|
||||
assert "B0 0b 32" in content, f"Expected CC11 B0 0b line, got: {content}"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_midi_source_cc_note_interleaved_order(self):
|
||||
"""CC events interleaved with notes in time order."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
note = MidiNote(pitch=60, start=0.5, duration=1.0, velocity=100)
|
||||
cc1 = CCEvent(controller=11, time=0.0, value=50)
|
||||
cc2 = CCEvent(controller=11, time=0.25, value=127)
|
||||
clip = ClipDef(
|
||||
position=0.0, length=16.0, name="Test",
|
||||
midi_notes=[note], midi_cc=[cc1, cc2],
|
||||
)
|
||||
track = TrackDef(name="MIDI CC", 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")
|
||||
# Find E-lines — CC should come before note in time order
|
||||
e_lines = [l.strip() for l in content.split('\n') if l.strip().startswith('E ')]
|
||||
# First E-line should be CC at time 0 (B0 0b)
|
||||
assert any('B0' in l for l in e_lines), f"No CC E-lines found in {e_lines}"
|
||||
# Find position of first CC line vs first note line
|
||||
cc_indices = [i for i, l in enumerate(e_lines) if 'B0' in l]
|
||||
note_indices = [i for i, l in enumerate(e_lines) if '90' in l]
|
||||
assert cc_indices[0] < note_indices[0], \
|
||||
f"CC at time 0 should come before note at time 0.5. E-lines: {e_lines}"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_midi_source_no_cc_when_empty(self):
|
||||
"""_build_midi_source emits no B0 lines when midi_cc is empty."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100)
|
||||
clip = ClipDef(
|
||||
position=0.0, length=16.0, name="Test",
|
||||
midi_notes=[note], midi_cc=[],
|
||||
)
|
||||
track = TrackDef(name="MIDI No CC", 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 "B0" not in content, f"No CC expected, got: {content}"
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user