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:
renato97
2026-05-03 23:54:29 -03:00
parent 48bc271afc
commit 014e636889
51 changed files with 11394 additions and 113 deletions

View 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