- 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.
477 lines
20 KiB
Python
477 lines
20 KiB
Python
"""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
|