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:
476
tests/test_transitions_fx.py
Normal file
476
tests/test_transitions_fx.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""Tests for transitions-fx — build_fx_track() and FX_TRANSITIONS map.
|
||||
|
||||
Strict TDD: tests written BEFORE implementation (per TDD cycle).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phase 1: RED — FX_TRANSITIONS dict + FX_ROLE constant
|
||||
# ============================================================================
|
||||
|
||||
class TestFxTransitionsDict:
|
||||
"""1.1 FX_TRANSITIONS dict: 7 boundaries → 8 clips."""
|
||||
|
||||
def test_fx_transitions_has_correct_boundary_count(self):
|
||||
"""FX_TRANSITIONS covers 7 section boundaries (indices 2-8)."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
assert isinstance(FX_TRANSITIONS, dict), "FX_TRANSITIONS must be a dict"
|
||||
# 7 boundaries: indices 2,3,4,5,6,7,8 (NOT 0,1 — intro/verse have no FX)
|
||||
expected_boundaries = {2, 3, 4, 5, 6, 7, 8}
|
||||
assert set(FX_TRANSITIONS.keys()) == expected_boundaries, (
|
||||
f"Expected boundaries {sorted(expected_boundaries)}, "
|
||||
f"got {sorted(FX_TRANSITIONS.keys())}"
|
||||
)
|
||||
|
||||
def test_fx_transitions_total_clips_8(self):
|
||||
"""FX_TRANSITIONS defines exactly 8 clips (one boundary has riser+impact)."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
total = 0
|
||||
for vals in FX_TRANSITIONS.values():
|
||||
if isinstance(vals, list):
|
||||
total += len(vals)
|
||||
else:
|
||||
total += 1
|
||||
assert total == 8, f"Expected 8 total clips, got {total}"
|
||||
|
||||
def test_fx_transitions_boundary_2_sweep(self):
|
||||
"""verse→pre-chorus: sweep at position 46, length 2, fade_in 0.3."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entry = FX_TRANSITIONS[2]
|
||||
# Boundary 2 has a single tuple (not nested list for single-clip boundaries)
|
||||
assert isinstance(entry, tuple), f"Boundary 2 should be a single tuple, got {type(entry)}"
|
||||
fx_type, offset, length, fade_in, fade_out = entry
|
||||
assert fx_type == "sweep"
|
||||
assert offset == -2, f"Expected offset -2 (boundary beat 48 - 2 = 46), got {offset}"
|
||||
assert length == 2
|
||||
assert fade_in == 0.3
|
||||
assert fade_out == 0.0
|
||||
|
||||
def test_fx_transitions_boundary_3_has_riser_and_impact(self):
|
||||
"""pre-chorus→chorus: list of 2 tuples (riser + impact)."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entries = FX_TRANSITIONS[3]
|
||||
assert isinstance(entries, list), f"Boundary 3 must be a list for riser+impact, got {type(entries)}"
|
||||
assert len(entries) == 2, f"Boundary 3 must have 2 entries, got {len(entries)}"
|
||||
|
||||
# Riser
|
||||
fx_type, offset, length, fade_in, fade_out = entries[0]
|
||||
assert fx_type == "riser"
|
||||
assert offset == -4, f"Expected offset -4 (boundary beat 64 - 4 = 60), got {offset}"
|
||||
assert length == 4
|
||||
assert fade_in == 1.5
|
||||
assert fade_out == 0.0
|
||||
|
||||
# Impact
|
||||
fx_type, offset, length, fade_in, fade_out = entries[1]
|
||||
assert fx_type == "impact"
|
||||
assert offset == 0, f"Expected offset 0 (boundary beat 64 + 0 = 64), got {offset}"
|
||||
assert length == 2
|
||||
assert fade_in == 0.0
|
||||
assert fade_out == 0.3
|
||||
|
||||
def test_fx_transitions_boundary_4_transition(self):
|
||||
"""chorus→verse2: transition at position 94, length 2, fades 0.2/0.2."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entry = FX_TRANSITIONS[4]
|
||||
fx_type, offset, length, fade_in, fade_out = entry
|
||||
assert fx_type == "transition"
|
||||
assert offset == -2, f"Expected offset -2 (boundary beat 96 - 2 = 94), got {offset}"
|
||||
assert length == 2
|
||||
assert fade_in == 0.2
|
||||
assert fade_out == 0.2
|
||||
|
||||
def test_fx_transitions_boundary_5_riser(self):
|
||||
"""verse2→chorus2: riser at position 124, length 4, fade_in 1.0."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entry = FX_TRANSITIONS[5]
|
||||
fx_type, offset, length, fade_in, fade_out = entry
|
||||
assert fx_type == "riser"
|
||||
assert offset == -4, f"Expected offset -4 (boundary beat 128 - 4 = 124), got {offset}"
|
||||
assert length == 4
|
||||
assert fade_in == 1.0
|
||||
assert fade_out == 0.0
|
||||
|
||||
def test_fx_transitions_boundary_6_sweep(self):
|
||||
"""chorus2→bridge: sweep at position 158, length 2, fades 0.2/0.2."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entry = FX_TRANSITIONS[6]
|
||||
fx_type, offset, length, fade_in, fade_out = entry
|
||||
assert fx_type == "sweep"
|
||||
assert offset == -2, f"Expected offset -2 (boundary beat 160 - 2 = 158), got {offset}"
|
||||
assert length == 2
|
||||
assert fade_in == 0.2
|
||||
assert fade_out == 0.2
|
||||
|
||||
def test_fx_transitions_boundary_7_riser(self):
|
||||
"""bridge→final: riser at position 172, length 4, fade_in 1.0."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entry = FX_TRANSITIONS[7]
|
||||
fx_type, offset, length, fade_in, fade_out = entry
|
||||
assert fx_type == "riser"
|
||||
assert offset == -4, f"Expected offset -4 (boundary beat 176 - 4 = 172), got {offset}"
|
||||
assert length == 4
|
||||
assert fade_in == 1.0
|
||||
assert fade_out == 0.0
|
||||
|
||||
def test_fx_transitions_boundary_8_sweep(self):
|
||||
"""final→outro: sweep at position 206, length 2, fades 0.3/0.5."""
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
entry = FX_TRANSITIONS[8]
|
||||
fx_type, offset, length, fade_in, fade_out = entry
|
||||
assert fx_type == "sweep"
|
||||
assert offset == -2, f"Expected offset -2 (boundary beat 208 - 2 = 206), got {offset}"
|
||||
assert length == 2
|
||||
assert fade_in == 0.3
|
||||
assert fade_out == 0.5
|
||||
|
||||
|
||||
class TestFxRoleConstant:
|
||||
"""1.2 FX_ROLE = 'fx' constant referencing ATONAL_ROLES."""
|
||||
|
||||
def test_fx_role_equals_fx(self):
|
||||
"""FX_ROLE is the string 'fx'."""
|
||||
from scripts.compose import FX_ROLE
|
||||
assert FX_ROLE == "fx"
|
||||
|
||||
def test_fx_role_in_atonal_roles(self):
|
||||
"""FX_ROLE value is in ATONAL_ROLES (skip key scoring)."""
|
||||
from scripts.compose import FX_ROLE
|
||||
from src.selector import ATONAL_ROLES
|
||||
assert FX_ROLE in ATONAL_ROLES, "fx must be in ATONAL_ROLES for neutral key scoring"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phase 2: RED — build_fx_track() function
|
||||
# ============================================================================
|
||||
|
||||
def _make_fx_selector(samples=None):
|
||||
"""Build a mock SampleSelector that returns FX samples via select_one."""
|
||||
if samples is None:
|
||||
samples = [
|
||||
{"original_path": f"fx_sample_{i}.wav", "original_name": f"FX {i}"}
|
||||
for i in range(5)
|
||||
]
|
||||
mock = MagicMock()
|
||||
mock.select_one.return_value = samples[0] if samples else None
|
||||
return mock
|
||||
|
||||
|
||||
def _make_sections_and_offsets():
|
||||
"""Return sections and offsets matching production SECTIONS layout."""
|
||||
from scripts.compose import SECTIONS
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = []
|
||||
for name, bars, energy, _has_lead in SECTIONS:
|
||||
sections.append(SectionDef(name=name, bars=bars, energy=energy))
|
||||
|
||||
offsets = []
|
||||
off = 0.0
|
||||
for sec in sections:
|
||||
offsets.append(off)
|
||||
off += sec.bars
|
||||
return sections, offsets
|
||||
|
||||
|
||||
class TestBuildFxTrack:
|
||||
"""4.1-4.3 Unit tests for build_fx_track()."""
|
||||
|
||||
def test_returns_trackdef_with_8_clips(self):
|
||||
"""build_fx_track produces a TrackDef with exactly 8 clips."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
assert track.name == "Transition FX"
|
||||
assert len(track.clips) == 8, f"Expected 8 clips, got {len(track.clips)}"
|
||||
|
||||
def test_clip_positions_match_design(self):
|
||||
"""Clip positions match the design boundary map values."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
|
||||
positions = sorted(c.position for c in track.clips)
|
||||
expected = [46.0, 60.0, 64.0, 94.0, 124.0, 158.0, 172.0, 206.0]
|
||||
assert positions == expected, (
|
||||
f"Expected positions {expected}, got {positions}"
|
||||
)
|
||||
|
||||
def test_all_clips_have_audio_path(self):
|
||||
"""All 8 clips have audio_path set (not None)."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
|
||||
for i, clip in enumerate(track.clips):
|
||||
assert clip.audio_path is not None, (
|
||||
f"Clip {i} (pos={clip.position}) has None audio_path"
|
||||
)
|
||||
assert isinstance(clip.audio_path, str), (
|
||||
f"Clip {i} audio_path must be a string"
|
||||
)
|
||||
assert len(clip.audio_path) > 0, (
|
||||
f"Clip {i} audio_path must not be empty"
|
||||
)
|
||||
|
||||
def test_fade_values_match_design(self):
|
||||
"""Fade in/out values match the design table for each clip."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
|
||||
# Build expected map: position → (fade_in, fade_out)
|
||||
expected_fades = {
|
||||
46.0: (0.3, 0.0), # sweep
|
||||
60.0: (1.5, 0.0), # riser
|
||||
64.0: (0.0, 0.3), # impact
|
||||
94.0: (0.2, 0.2), # transition
|
||||
124.0: (1.0, 0.0), # riser
|
||||
158.0: (0.2, 0.2), # sweep
|
||||
172.0: (1.0, 0.0), # riser
|
||||
206.0: (0.3, 0.5), # sweep
|
||||
}
|
||||
|
||||
for clip in track.clips:
|
||||
exp_fi, exp_fo = expected_fades.get(clip.position, (None, None))
|
||||
assert exp_fi is not None, f"Unexpected clip position: {clip.position}"
|
||||
assert clip.fade_in == pytest.approx(exp_fi), (
|
||||
f"Clip at {clip.position}: fade_in={clip.fade_in}, expected {exp_fi}"
|
||||
)
|
||||
assert clip.fade_out == pytest.approx(exp_fo), (
|
||||
f"Clip at {clip.position}: fade_out={clip.fade_out}, expected {exp_fo}"
|
||||
)
|
||||
|
||||
def test_riser_has_fade_in_gt_zero(self):
|
||||
"""Spec requirement: riser clips have fade_in > 0."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
|
||||
# Find which entries are risers
|
||||
riser_positions = set()
|
||||
for bound_idx, entries in FX_TRANSITIONS.items():
|
||||
items = entries if isinstance(entries, list) else [entries]
|
||||
for fx_type, offset, length, fi, fo in items:
|
||||
if fx_type == "riser":
|
||||
boundary_beat = offsets[bound_idx] * 4.0
|
||||
riser_positions.add(boundary_beat + offset)
|
||||
|
||||
for clip in track.clips:
|
||||
if clip.position in riser_positions:
|
||||
assert clip.fade_in > 0, (
|
||||
f"Riser at {clip.position} must have fade_in > 0"
|
||||
)
|
||||
|
||||
def test_impact_has_fade_out_gt_zero(self):
|
||||
"""Spec requirement: impact clips have fade_out > 0."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
from scripts.compose import FX_TRANSITIONS
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
|
||||
# Find which entries are impacts
|
||||
impact_positions = set()
|
||||
for bound_idx, entries in FX_TRANSITIONS.items():
|
||||
items = entries if isinstance(entries, list) else [entries]
|
||||
for fx_type, offset, length, fi, fo in items:
|
||||
if fx_type == "impact":
|
||||
boundary_beat = offsets[bound_idx] * 4.0
|
||||
impact_positions.add(boundary_beat + offset)
|
||||
|
||||
for clip in track.clips:
|
||||
if clip.position in impact_positions:
|
||||
assert clip.fade_out > 0, (
|
||||
f"Impact at {clip.position} must have fade_out > 0"
|
||||
)
|
||||
|
||||
def test_track_volume_is_0_72(self):
|
||||
"""Spec requirement: FX track volume = 0.72."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
assert track.volume == 0.72, f"Expected volume 0.72, got {track.volume}"
|
||||
|
||||
def test_track_has_send_level(self):
|
||||
"""Spec requirement: FX track sends to Reverb (0.08) and Delay (0.05)."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
assert track.send_level == {0: 0.08, 1: 0.05}, (
|
||||
f"Expected send_level {{0: 0.08, 1: 0.05}}, got {track.send_level}"
|
||||
)
|
||||
|
||||
def test_deterministic_with_same_seed(self):
|
||||
"""Same seed produces identical output (deterministic via select_one)."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector1 = _make_fx_selector()
|
||||
selector2 = _make_fx_selector()
|
||||
track1 = build_fx_track(sections, offsets, selector1, seed=42)
|
||||
track2 = build_fx_track(sections, offsets, selector2, seed=42)
|
||||
paths1 = [c.audio_path for c in track1.clips]
|
||||
paths2 = [c.audio_path for c in track2.clips]
|
||||
assert paths1 == paths2, "Same seed must produce same sample paths"
|
||||
|
||||
def test_different_seed_may_produce_different(self):
|
||||
"""Different seeds use different calls to select_one (variation)."""
|
||||
from scripts.compose import build_fx_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
selector = _make_fx_selector()
|
||||
track = build_fx_track(sections, offsets, selector, seed=0)
|
||||
# Verify that select_one was called with the correct role
|
||||
all_calls = selector.select_one.call_args_list
|
||||
for call in all_calls:
|
||||
assert call[1]["role"] == "fx", (
|
||||
f"select_one must be called with role='fx', got {call}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phase 3: RED — Integration (build_fx_track in main)
|
||||
# ============================================================================
|
||||
|
||||
class TestFxTrackIntegration:
|
||||
"""3.1-3.2 Integration: build_fx_track called in main(), send wiring works."""
|
||||
|
||||
def test_transition_fx_track_in_main_output(self, tmp_path):
|
||||
"""main() produces RPP output containing 'Transition FX' track."""
|
||||
output = _mock_main_fx(tmp_path)
|
||||
assert output.exists(), f"Expected {output} to exist"
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Transition FX" in content, (
|
||||
"Expected 'Transition FX' track in RPP output"
|
||||
)
|
||||
|
||||
def test_fx_track_has_audio_source(self, tmp_path):
|
||||
"""FX track clips produce SOURCE WAVE entries."""
|
||||
output = _mock_main_fx(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# Count SOURCE WAVE entries — should include FX clips
|
||||
wave_count = content.count("SOURCE WAVE")
|
||||
assert wave_count > 2, f"Expected multiple WAVE sources, got {wave_count}"
|
||||
|
||||
def test_fx_track_has_aux_sends(self, tmp_path):
|
||||
"""FX track has AUXRECV for reverb/delay sends."""
|
||||
output = _mock_main_fx(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "AUXRECV" in content, "Expected send routing for FX track"
|
||||
|
||||
def test_fx_track_after_clap_before_pad(self, tmp_path):
|
||||
"""Track ordering: Clap → FX → Pad."""
|
||||
output = _mock_main_fx(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
|
||||
# RPP format: <TRACK opens on one line, NAME on the next line
|
||||
# Names with spaces are quoted: NAME "808 Bass", single-word: NAME Drumloop
|
||||
import re
|
||||
track_names = re.findall(
|
||||
r'<TRACK \{[^}]+\}\s*\n\s*NAME "?([^"\n]+)"?',
|
||||
content,
|
||||
)
|
||||
assert "Clap" in track_names, f"Expected Clap in tracks: {track_names}"
|
||||
assert "Transition FX" in track_names, f"Expected Transition FX in tracks: {track_names}"
|
||||
assert "Pad" in track_names, f"Expected Pad in tracks: {track_names}"
|
||||
|
||||
clap_pos = track_names.index("Clap")
|
||||
fx_pos = track_names.index("Transition FX")
|
||||
pad_pos = track_names.index("Pad")
|
||||
assert clap_pos < fx_pos < pad_pos, (
|
||||
f"Expected Clap ({clap_pos}) < FX ({fx_pos}) < Pad ({pad_pos})"
|
||||
)
|
||||
|
||||
def test_calibrate_does_not_break_fx_track(self, tmp_path):
|
||||
"""Calibrator.apply() does not crash on FX track."""
|
||||
output = _mock_main_fx(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Transition FX" in content, (
|
||||
"FX track must survive calibration"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helpers for integration tests
|
||||
# ============================================================================
|
||||
|
||||
def _mock_main_fx(tmp_path, extra_args=None):
|
||||
"""Mock compose.py main() with FX-capable selector."""
|
||||
import sys as _sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
output = tmp_path / "track.rpp"
|
||||
|
||||
# Build mock with FX samples that select_one will return
|
||||
fx_samples = [
|
||||
{
|
||||
"role": "fx",
|
||||
"perceptual": {"tempo": 0},
|
||||
"musical": {"key": "X"},
|
||||
"character": "dark",
|
||||
"original_path": f"fx_sample_{i}.wav",
|
||||
"original_name": f"Transition_FX_{i}.wav",
|
||||
"file_hash": f"fx{i:04d}",
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_cls:
|
||||
mock_sel = MagicMock()
|
||||
mock_sel._samples = fx_samples + [
|
||||
{
|
||||
"role": "snare",
|
||||
"perceptual": {"tempo": 0},
|
||||
"musical": {"key": "X"},
|
||||
"character": "sharp",
|
||||
"original_path": "fake_clap.wav",
|
||||
"original_name": "fake_clap.wav",
|
||||
"file_hash": "clap123",
|
||||
},
|
||||
]
|
||||
|
||||
# Mock select for clap builder
|
||||
mock_sel.select.return_value = [
|
||||
MagicMock(sample={
|
||||
"original_path": "fake_clap.wav",
|
||||
"file_hash": "clap123",
|
||||
}),
|
||||
]
|
||||
# Mock select_one for FX builder — returns real dicts
|
||||
mock_sel.select_one.return_value = fx_samples[0]
|
||||
|
||||
mock_sel.select_diverse.return_value = [
|
||||
{
|
||||
"original_path": "fake_vocal.wav",
|
||||
"file_hash": "vox123",
|
||||
},
|
||||
]
|
||||
mock_cls.return_value = mock_sel
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user