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:
631
tests/test_calibrator.py
Normal file
631
tests/test_calibrator.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""Unit tests for the calibrator module — role-based mix calibration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1.1: Presets data structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPresets:
|
||||
"""Verify that all preset dictionaries exist with correct structure."""
|
||||
|
||||
def test_volume_presets_has_all_roles(self):
|
||||
"""VOLUME_PRESETS must contain all 7 roles with float values."""
|
||||
from src.calibrator.presets import VOLUME_PRESETS
|
||||
|
||||
assert isinstance(VOLUME_PRESETS, dict)
|
||||
expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
|
||||
assert set(VOLUME_PRESETS.keys()) == expected_roles
|
||||
for role, vol in VOLUME_PRESETS.items():
|
||||
assert isinstance(vol, float), f"Volume for {role} should be float"
|
||||
assert 0.0 <= vol <= 1.0, f"Volume for {role} must be 0-1, got {vol}"
|
||||
|
||||
def test_volume_presets_correct_values(self):
|
||||
"""VOLUME_PRESETS values must match the calibrated targets."""
|
||||
from src.calibrator.presets import VOLUME_PRESETS
|
||||
|
||||
assert VOLUME_PRESETS["drumloop"] == 0.85
|
||||
assert VOLUME_PRESETS["bass"] == 0.82
|
||||
assert VOLUME_PRESETS["chords"] == 0.75
|
||||
assert VOLUME_PRESETS["lead"] == 0.80
|
||||
assert VOLUME_PRESETS["clap"] == 0.78
|
||||
assert VOLUME_PRESETS["pad"] == 0.70
|
||||
assert VOLUME_PRESETS["perc"] == 0.80
|
||||
|
||||
def test_eq_presets_has_all_roles(self):
|
||||
"""EQ_PRESETS must contain the 7 roles with ReaEQ param dicts."""
|
||||
from src.calibrator.presets import EQ_PRESETS
|
||||
|
||||
expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
|
||||
assert set(EQ_PRESETS.keys()) == expected_roles
|
||||
for role, params in EQ_PRESETS.items():
|
||||
assert isinstance(params, dict), f"EQ for {role} should be dict"
|
||||
assert 0 in params, f"EQ for {role} must have slot 0 (enabled)"
|
||||
assert 1 in params, f"EQ for {role} must have slot 1 (filter type)"
|
||||
assert 2 in params, f"EQ for {role} must have slot 2 (frequency)"
|
||||
|
||||
def test_eq_presets_hpf_lpf(self):
|
||||
"""EQ_PRESETS must have correct HPF/LPF assignments."""
|
||||
from src.calibrator.presets import EQ_PRESETS
|
||||
|
||||
# HPF at 60Hz for drums
|
||||
assert EQ_PRESETS["drumloop"][1] == 1 # HPF type
|
||||
assert EQ_PRESETS["drumloop"][2] == 60.0
|
||||
|
||||
# LPF at 300Hz for bass
|
||||
assert EQ_PRESETS["bass"][1] == 0 # LPF type
|
||||
assert EQ_PRESETS["bass"][2] == 300.0
|
||||
|
||||
# HPF at 200Hz for chords, lead, clap
|
||||
for role in ("chords", "lead", "clap", "perc"):
|
||||
assert EQ_PRESETS[role][1] == 1, f"{role} should be HPF"
|
||||
assert EQ_PRESETS[role][2] == 200.0, f"{role} HPF should be 200Hz"
|
||||
|
||||
# HPF at 100Hz for pad
|
||||
assert EQ_PRESETS["pad"][1] == 1 # HPF type
|
||||
assert EQ_PRESETS["pad"][2] == 100.0
|
||||
|
||||
def test_pan_presets_has_all_roles(self):
|
||||
"""PAN_PRESETS must contain all 7 roles with float pan values."""
|
||||
from src.calibrator.presets import PAN_PRESETS
|
||||
|
||||
expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
|
||||
assert set(PAN_PRESETS.keys()) == expected_roles
|
||||
for role, pan in PAN_PRESETS.items():
|
||||
assert isinstance(pan, float), f"Pan for {role} should be float"
|
||||
assert -1.0 <= pan <= 1.0, f"Pan for {role} must be -1 to 1, got {pan}"
|
||||
|
||||
def test_pan_presets_correct_values(self):
|
||||
"""PAN_PRESETS must match spec values."""
|
||||
from src.calibrator.presets import PAN_PRESETS
|
||||
|
||||
assert PAN_PRESETS["drumloop"] == 0.0
|
||||
assert PAN_PRESETS["bass"] == 0.0
|
||||
assert PAN_PRESETS["chords"] == 0.5
|
||||
assert PAN_PRESETS["lead"] == 0.3
|
||||
assert PAN_PRESETS["clap"] == -0.15
|
||||
assert PAN_PRESETS["pad"] == -0.5
|
||||
assert PAN_PRESETS["perc"] == 0.12
|
||||
|
||||
def test_send_presets_has_all_roles(self):
|
||||
"""SEND_PRESETS must contain all 7 roles with (reverb, delay) tuples."""
|
||||
from src.calibrator.presets import SEND_PRESETS
|
||||
|
||||
expected_roles = {"drumloop", "bass", "chords", "lead", "clap", "pad", "perc"}
|
||||
assert set(SEND_PRESETS.keys()) == expected_roles
|
||||
for role, sends in SEND_PRESETS.items():
|
||||
assert isinstance(sends, tuple), f"Sends for {role} should be tuple"
|
||||
assert len(sends) == 2, f"Sends for {role} should be (reverb, delay)"
|
||||
assert all(0.0 <= s <= 1.0 for s in sends), f"Sends out of range for {role}"
|
||||
|
||||
def test_send_presets_correct_values(self):
|
||||
"""SEND_PRESETS must match task values with spec fallback."""
|
||||
from src.calibrator.presets import SEND_PRESETS
|
||||
|
||||
assert SEND_PRESETS["drumloop"] == (0.10, 0.00)
|
||||
assert SEND_PRESETS["bass"] == (0.05, 0.00) # task override: delay 0.0
|
||||
assert SEND_PRESETS["chords"] == (0.40, 0.10) # task value
|
||||
assert SEND_PRESETS["lead"] == (0.30, 0.15) # task override: reverb 0.30
|
||||
assert SEND_PRESETS["clap"] == (0.10, 0.00)
|
||||
assert SEND_PRESETS["pad"] == (0.50, 0.20) # task value
|
||||
assert SEND_PRESETS["perc"] == (0.10, 0.00)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1.2: SongMeta.calibrate field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSongMetaCalibrate:
|
||||
"""Verify SongMeta has calibrate: bool = True field."""
|
||||
|
||||
def test_song_meta_default_calibrate_true(self):
|
||||
"""SongMeta should default calibrate to True."""
|
||||
from src.core.schema import SongMeta
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
assert meta.calibrate is True
|
||||
|
||||
def test_song_meta_calibrate_false(self):
|
||||
"""SongMeta should accept calibrate=False."""
|
||||
from src.core.schema import SongMeta
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am", calibrate=False)
|
||||
assert meta.calibrate is False
|
||||
|
||||
def test_song_meta_serialization_includes_calibrate(self):
|
||||
"""to_json should include the calibrate field."""
|
||||
from src.core.schema import SongMeta
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
json_str = meta.__dict__
|
||||
assert "calibrate" in json_str
|
||||
assert json_str["calibrate"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1.3: Calibrator._resolve_role()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveRole:
|
||||
"""Verify track name → role key mapping."""
|
||||
|
||||
@staticmethod
|
||||
def _cal():
|
||||
from src.calibrator import Calibrator
|
||||
return Calibrator
|
||||
|
||||
def test_drumloop_roles(self):
|
||||
"""Drumloop track names should resolve to drumloop role."""
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Drumloop") == "drumloop"
|
||||
assert C._resolve_role("drumloop") == "drumloop"
|
||||
|
||||
def test_bass_roles(self):
|
||||
"""Bass track names should resolve to bass role."""
|
||||
C = self._cal()
|
||||
assert C._resolve_role("808 Bass") == "bass"
|
||||
assert C._resolve_role("808 bass") == "bass"
|
||||
assert C._resolve_role("bass") == "bass"
|
||||
|
||||
def test_chords_role(self):
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Chords") == "chords"
|
||||
assert C._resolve_role("chords") == "chords"
|
||||
|
||||
def test_lead_role(self):
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Lead") == "lead"
|
||||
assert C._resolve_role("lead") == "lead"
|
||||
|
||||
def test_clap_role(self):
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Clap") == "clap"
|
||||
assert C._resolve_role("clap") == "clap"
|
||||
|
||||
def test_pad_role(self):
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Pad") == "pad"
|
||||
assert C._resolve_role("pad") == "pad"
|
||||
|
||||
def test_perc_role(self):
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Perc") == "perc"
|
||||
assert C._resolve_role("perc") == "perc"
|
||||
|
||||
def test_return_tracks_return_none(self):
|
||||
"""Return tracks (Reverb, Delay) should return None."""
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Reverb") is None
|
||||
assert C._resolve_role("Delay") is None
|
||||
|
||||
def test_unknown_track_returns_none(self):
|
||||
"""Unknown track names should return None."""
|
||||
C = self._cal()
|
||||
assert C._resolve_role("Unknown") is None
|
||||
assert C._resolve_role("Vocals") is None
|
||||
assert C._resolve_role("") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: Core Calibrator methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import pytest as _pytest
|
||||
from src.core.schema import (
|
||||
SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef,
|
||||
)
|
||||
|
||||
|
||||
def _make_fixture_song() -> SongDefinition:
|
||||
"""Create a SongDefinition with representative tracks."""
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
tracks = [
|
||||
TrackDef(name="Drumloop", volume=0.85, plugins=[]),
|
||||
TrackDef(name="Perc", volume=0.78, plugins=[]),
|
||||
TrackDef(name="808 Bass", volume=0.72, plugins=[]),
|
||||
TrackDef(name="Chords", volume=0.70, plugins=[]),
|
||||
TrackDef(name="Lead", volume=0.75, plugins=[]),
|
||||
TrackDef(name="Clap", volume=0.80, plugins=[]),
|
||||
TrackDef(name="Pad", volume=0.65, plugins=[]),
|
||||
TrackDef(name="Reverb", volume=0.80, plugins=[]),
|
||||
TrackDef(name="Delay", volume=0.80, plugins=[]),
|
||||
]
|
||||
return SongDefinition(
|
||||
meta=meta,
|
||||
tracks=tracks,
|
||||
master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
|
||||
)
|
||||
|
||||
|
||||
class TestCalibrateVolumes:
|
||||
def test_volumes_calibrated_to_presets(self):
|
||||
"""_calibrate_volumes should set volume from VOLUME_PRESETS by role."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._calibrate_volumes(song)
|
||||
|
||||
volumes = {t.name: t.volume for t in song.tracks}
|
||||
assert volumes["Drumloop"] == 0.85
|
||||
assert volumes["808 Bass"] == 0.82
|
||||
assert volumes["Chords"] == 0.75
|
||||
assert volumes["Lead"] == 0.80
|
||||
assert volumes["Clap"] == 0.78
|
||||
assert volumes["Pad"] == 0.70
|
||||
assert volumes["Perc"] == 0.80
|
||||
|
||||
def test_return_tracks_volume_unchanged(self):
|
||||
"""Return tracks should not have their volume modified."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._calibrate_volumes(song)
|
||||
|
||||
rev = [t for t in song.tracks if t.name == "Reverb"][0]
|
||||
dly = [t for t in song.tracks if t.name == "Delay"][0]
|
||||
assert rev.volume == 0.80 # unchanged
|
||||
assert dly.volume == 0.80 # unchanged
|
||||
|
||||
def test_unknown_role_volume_unchanged(self):
|
||||
"""Tracks with unknown role should keep their original volume."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=[TrackDef(name="UnknownThing", volume=0.42)],
|
||||
)
|
||||
Calibrator._calibrate_volumes(song)
|
||||
assert song.tracks[0].volume == 0.42 # unchanged
|
||||
|
||||
|
||||
class TestCalibratePans:
|
||||
def test_pans_calibrated_to_presets(self):
|
||||
"""_calibrate_pans should set pan from PAN_PRESETS."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._calibrate_pans(song)
|
||||
|
||||
pans = {t.name: t.pan for t in song.tracks}
|
||||
assert pans["Drumloop"] == 0.0
|
||||
assert pans["808 Bass"] == 0.0
|
||||
assert pans["Chords"] == 0.5
|
||||
assert pans["Lead"] == 0.3
|
||||
assert pans["Clap"] == -0.15
|
||||
assert pans["Pad"] == -0.5
|
||||
assert pans["Perc"] == 0.12
|
||||
|
||||
def test_return_tracks_pan_unchanged(self):
|
||||
"""Return tracks should keep their original pan."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
# Give return tracks non-zero pans to verify they're preserved
|
||||
for t in song.tracks:
|
||||
if t.name in ("Reverb", "Delay"):
|
||||
t.pan = 0.5
|
||||
Calibrator._calibrate_pans(song)
|
||||
|
||||
rev = [t for t in song.tracks if t.name == "Reverb"][0]
|
||||
dly = [t for t in song.tracks if t.name == "Delay"][0]
|
||||
assert rev.pan == 0.5
|
||||
assert dly.pan == 0.5
|
||||
|
||||
def test_unknown_role_pan_unchanged(self):
|
||||
"""Unknown roles should keep original pan."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=[TrackDef(name="Vocals", pan=0.75)],
|
||||
)
|
||||
Calibrator._calibrate_pans(song)
|
||||
assert song.tracks[0].pan == 0.75
|
||||
|
||||
|
||||
class TestCalibrateSends:
|
||||
def test_sends_calibrated_to_presets(self):
|
||||
"""_calibrate_sends should set send_level dict from SEND_PRESETS."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
# 7 content tracks + 2 return tracks = 9 total
|
||||
# Reverb is at index 7, Delay at index 8
|
||||
Calibrator._calibrate_sends(song)
|
||||
|
||||
drum = [t for t in song.tracks if t.name == "Drumloop"][0]
|
||||
assert drum.send_level.get(7) == 0.10
|
||||
assert drum.send_level.get(8) == 0.00
|
||||
|
||||
bass = [t for t in song.tracks if t.name == "808 Bass"][0]
|
||||
assert bass.send_level.get(7) == 0.05
|
||||
assert bass.send_level.get(8) == 0.00
|
||||
|
||||
chords = [t for t in song.tracks if t.name == "Chords"][0]
|
||||
assert chords.send_level.get(7) == 0.40
|
||||
assert chords.send_level.get(8) == 0.10
|
||||
|
||||
lead = [t for t in song.tracks if t.name == "Lead"][0]
|
||||
assert lead.send_level.get(7) == 0.30
|
||||
assert lead.send_level.get(8) == 0.15
|
||||
|
||||
clap = [t for t in song.tracks if t.name == "Clap"][0]
|
||||
assert clap.send_level.get(7) == 0.10
|
||||
assert clap.send_level.get(8) == 0.00
|
||||
|
||||
pad = [t for t in song.tracks if t.name == "Pad"][0]
|
||||
assert pad.send_level.get(7) == 0.50
|
||||
assert pad.send_level.get(8) == 0.20
|
||||
|
||||
perc = [t for t in song.tracks if t.name == "Perc"][0]
|
||||
assert perc.send_level.get(7) == 0.10
|
||||
assert perc.send_level.get(8) == 0.00
|
||||
|
||||
def test_return_tracks_sends_unchanged(self):
|
||||
"""Return tracks should not have sends set."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._calibrate_sends(song)
|
||||
|
||||
rev = [t for t in song.tracks if t.name == "Reverb"][0]
|
||||
dly = [t for t in song.tracks if t.name == "Delay"][0]
|
||||
assert rev.send_level == {}
|
||||
assert dly.send_level == {}
|
||||
|
||||
|
||||
class TestCalibrateEq:
|
||||
def test_reaeq_plugin_prepended(self):
|
||||
"""_calibrate_eq should prepend ReaEQ with HPF/LPF params to non-return tracks."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._calibrate_eq(song)
|
||||
|
||||
# Drumloop: HPF 60Hz
|
||||
drum = [t for t in song.tracks if t.name == "Drumloop"][0]
|
||||
assert len(drum.plugins) >= 1
|
||||
eq = drum.plugins[0]
|
||||
assert eq.name == "ReaEQ"
|
||||
assert eq.params[0] == 1
|
||||
assert eq.params[1] == 1 # HPF
|
||||
assert eq.params[2] == 60.0
|
||||
|
||||
# Bass: LPF 300Hz
|
||||
bass = [t for t in song.tracks if t.name == "808 Bass"][0]
|
||||
eq = bass.plugins[0]
|
||||
assert eq.name == "ReaEQ"
|
||||
assert eq.params[1] == 0 # LPF
|
||||
assert eq.params[2] == 300.0
|
||||
|
||||
# Chords: HPF 200Hz
|
||||
chords = [t for t in song.tracks if t.name == "Chords"][0]
|
||||
eq = chords.plugins[0]
|
||||
assert eq.params[1] == 1 # HPF
|
||||
assert eq.params[2] == 200.0
|
||||
|
||||
# Pad: HPF 100Hz
|
||||
pad = [t for t in song.tracks if t.name == "Pad"][0]
|
||||
eq = pad.plugins[0]
|
||||
assert eq.params[1] == 1 # HPF
|
||||
assert eq.params[2] == 100.0
|
||||
|
||||
def test_return_tracks_no_reaeq(self):
|
||||
"""Return tracks should not get ReaEQ plugins."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._calibrate_eq(song)
|
||||
|
||||
rev = [t for t in song.tracks if t.name == "Reverb"][0]
|
||||
dly = [t for t in song.tracks if t.name == "Delay"][0]
|
||||
assert rev.plugins == []
|
||||
assert dly.plugins == []
|
||||
|
||||
def test_reaeq_index_zero(self):
|
||||
"""ReaEQ must be at index 0 (prepended to existing plugins)."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
# Add an existing plugin to a track
|
||||
lead = [t for t in song.tracks if t.name == "Lead"][0]
|
||||
lead.plugins = [PluginDef(name="Serum 2", path="Serum2.vst3", index=0)]
|
||||
|
||||
Calibrator._calibrate_eq(song)
|
||||
|
||||
assert len(lead.plugins) == 2
|
||||
assert lead.plugins[0].name == "ReaEQ"
|
||||
assert lead.plugins[0].index == 0
|
||||
assert lead.plugins[1].name == "Serum 2"
|
||||
|
||||
def test_unknown_role_no_reaeq(self):
|
||||
"""Tracks with unknown role should not get ReaEQ."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=[TrackDef(name="Vocals", plugins=[])],
|
||||
)
|
||||
Calibrator._calibrate_eq(song)
|
||||
assert song.tracks[0].plugins == []
|
||||
|
||||
|
||||
class TestSwapMasterChain:
|
||||
def test_ozone12_master_chain(self):
|
||||
"""_swap_master_chain should replace master_plugins with Ozone 12 triplet."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._swap_master_chain(song)
|
||||
assert song.master_plugins == [
|
||||
"Ozone_12_Equalizer",
|
||||
"Ozone_12_Dynamics",
|
||||
"Ozone_12_Maximizer",
|
||||
]
|
||||
|
||||
def test_fallback_to_fabfilter(self):
|
||||
"""When Ozone is not in PLUGIN_REGISTRY, fall back to FabFilter trio."""
|
||||
from src.calibrator import Calibrator
|
||||
from src.reaper_builder import PLUGIN_REGISTRY
|
||||
|
||||
# Make a copy of the registry without Ozone entries
|
||||
original = dict(PLUGIN_REGISTRY)
|
||||
try:
|
||||
for k in list(PLUGIN_REGISTRY.keys()):
|
||||
if k.startswith("Ozone_12_"):
|
||||
del PLUGIN_REGISTRY[k] # type: ignore
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator._swap_master_chain(song)
|
||||
assert song.master_plugins == ["Pro-Q_3", "Pro-C_2", "Pro-L_2"]
|
||||
finally:
|
||||
# Restore
|
||||
PLUGIN_REGISTRY.clear()
|
||||
PLUGIN_REGISTRY.update(original)
|
||||
|
||||
|
||||
class TestCalibratorApply:
|
||||
def test_apply_orchestrates_all_calibrations(self):
|
||||
"""Calibrator.apply() should run all _calibrate_* and _swap_master_chain."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
result = Calibrator.apply(song)
|
||||
|
||||
# Same object returned (in-place mutation)
|
||||
assert result is song
|
||||
|
||||
# Volumes applied
|
||||
drum = [t for t in song.tracks if t.name == "Drumloop"][0]
|
||||
assert drum.volume == 0.85
|
||||
|
||||
# Pans applied
|
||||
assert drum.pan == 0.0
|
||||
|
||||
# Sends applied
|
||||
assert drum.send_level.get(7) == 0.10
|
||||
|
||||
# EQ applied (ReaEQ present)
|
||||
assert drum.plugins[0].name == "ReaEQ"
|
||||
|
||||
# Master chain upgraded
|
||||
assert song.master_plugins == [
|
||||
"Ozone_12_Equalizer",
|
||||
"Ozone_12_Dynamics",
|
||||
"Ozone_12_Maximizer",
|
||||
]
|
||||
|
||||
def test_apply_skips_bass_lpf_eq(self):
|
||||
"""Bass track should get LPF, not HPF."""
|
||||
from src.calibrator import Calibrator
|
||||
|
||||
song = _make_fixture_song()
|
||||
Calibrator.apply(song)
|
||||
|
||||
bass = [t for t in song.tracks if t.name == "808 Bass"][0]
|
||||
eq = bass.plugins[0]
|
||||
assert eq.params[1] == 0 # LPF type
|
||||
assert eq.params[2] == 300.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: Builder integration + compose wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildPluginWithParams:
|
||||
"""Verify _build_plugin populates VST2 param slots from PluginDef.params."""
|
||||
|
||||
def test_build_plugin_with_params_sets_param_slots(self):
|
||||
"""PluginDef with params for built-in VST2 should populate param slots."""
|
||||
from src.reaper_builder import RPPBuilder
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, PluginDef
|
||||
|
||||
plugin = PluginDef(
|
||||
name="ReaEQ",
|
||||
path="reaeq.dll",
|
||||
index=0,
|
||||
params={0: 1, 1: 1, 2: 200.0},
|
||||
)
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[TrackDef(name="Test", plugins=[plugin])])
|
||||
builder = RPPBuilder(song, seed=0)
|
||||
elem = builder._build_plugin(plugin)
|
||||
|
||||
# When params is non-empty for a built-in VST2, use built-in path
|
||||
# Format: [display_name, filename, "0", "", *param_slots]
|
||||
attrs = elem.attrib
|
||||
assert attrs[0] == "ReaEQ"
|
||||
assert attrs[2] == "0"
|
||||
# attrs[4:] are the 19 param slots
|
||||
param_slots = attrs[4:]
|
||||
assert len(param_slots) == 19
|
||||
assert param_slots[0] == "1" # band enabled
|
||||
assert param_slots[1] == "1" # filter type (HPF)
|
||||
assert param_slots[2] == "200.0" # frequency
|
||||
|
||||
def test_build_plugin_without_params_uses_zeros(self):
|
||||
"""PluginDef without params for built-in VST2 should use default zeros."""
|
||||
from src.reaper_builder import RPPBuilder
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, PluginDef
|
||||
|
||||
plugin = PluginDef(
|
||||
name="FakeBuiltin",
|
||||
path="fakebuiltin.dll",
|
||||
index=0,
|
||||
params={},
|
||||
)
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
song = SongDefinition(meta=meta, tracks=[TrackDef(name="Test", plugins=[plugin])])
|
||||
builder = RPPBuilder(song, seed=0)
|
||||
elem = builder._build_plugin(plugin)
|
||||
|
||||
param_slots = elem.attrib[4:]
|
||||
assert all(s == "0" for s in param_slots)
|
||||
|
||||
|
||||
class TestNoCalibrateFlag:
|
||||
"""Verify --no-calibrate CLI flag behavior."""
|
||||
|
||||
def test_no_calibrate_preserves_original_master(self):
|
||||
"""When calibrator is skipped, master_plugins should stay unchanged."""
|
||||
from src.core.schema import SongDefinition, SongMeta
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am", calibrate=False)
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=[],
|
||||
master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
|
||||
)
|
||||
|
||||
if meta.calibrate:
|
||||
from src.calibrator import Calibrator
|
||||
Calibrator.apply(song)
|
||||
|
||||
assert song.master_plugins == ["Pro-Q_3", "Pro-C_2", "Pro-L_2"]
|
||||
|
||||
def test_calibrate_flag_is_true_default(self):
|
||||
"""SongMeta.calibrate should default to True (calibration enabled)."""
|
||||
from src.core.schema import SongMeta
|
||||
meta = SongMeta(bpm=95, key="Am")
|
||||
assert meta.calibrate is True
|
||||
|
||||
def test_no_calibrate_skips_volume_changes(self):
|
||||
"""When calibrate=False, volumes should not be touched."""
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef
|
||||
|
||||
meta = SongMeta(bpm=95, key="Am", calibrate=False)
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=[TrackDef(name="Drumloop", volume=0.5)],
|
||||
)
|
||||
|
||||
if meta.calibrate:
|
||||
from src.calibrator import Calibrator
|
||||
Calibrator.apply(song)
|
||||
|
||||
assert song.tracks[0].volume == 0.5 # unchanged
|
||||
312
tests/test_chords.py
Normal file
312
tests/test_chords.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Unit tests for ChordEngine — determinism, voice leading, inversions, emotions.
|
||||
|
||||
Strict TDD: RED → GREEN → TRIANGULATE → REFACTOR for each spec requirement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from src.composer.chords import ChordEngine, EMOTION_PROGRESSIONS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R1: Determinism — same seed → same output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeterminism:
|
||||
"""R1: ChordEngine(key, seed) MUST produce identical progressions."""
|
||||
|
||||
def test_same_seed_same_output(self):
|
||||
"""GIVEN ChordEngine("Am", seed=42)
|
||||
WHEN progression(8) called twice
|
||||
THEN identical output."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
r1 = engine.progression(8)
|
||||
r2 = engine.progression(8)
|
||||
assert r1 == r2, "Same seed must produce identical progressions"
|
||||
assert len(r1) == 8, "8 bars @ 4 bpc = 8 chords"
|
||||
|
||||
def test_different_seed_different_output(self):
|
||||
"""Different seeds SHOULD produce different voicing choices."""
|
||||
e1 = ChordEngine("Am", seed=42)
|
||||
e2 = ChordEngine("Am", seed=99)
|
||||
r1 = e1.progression(8)
|
||||
r2 = e2.progression(8)
|
||||
assert r1 != r2, (
|
||||
"Different seeds should produce different voicings "
|
||||
"(rng used as voice-leading tiebreaker)"
|
||||
)
|
||||
|
||||
def test_same_seed_different_keys_differ(self):
|
||||
"""Same seed with different keys should differ."""
|
||||
e_am = ChordEngine("Am", seed=42)
|
||||
e_dm = ChordEngine("Dm", seed=42)
|
||||
r1 = e_am.progression(8)
|
||||
r2 = e_dm.progression(8)
|
||||
assert r1 != r2, "Different tonic should produce different pitch sets"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2: Voice leading ≤ 4 semitones per voice
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVoiceLeadingBounds:
|
||||
"""R2: Voice leading MUST cap at 4 semitones per voice."""
|
||||
|
||||
def test_all_adjacent_pairs_within_4_semitones(self):
|
||||
"""GIVEN any 2 consecutive chords from a progression
|
||||
WHEN computing voice leading
|
||||
THEN no voice moves more than 4 semitones."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
voicings = engine.progression(8, emotion="romantic")
|
||||
assert len(voicings) >= 2, "Need at least 2 chords to test voice leading"
|
||||
|
||||
for i in range(len(voicings) - 1):
|
||||
a = voicings[i]
|
||||
b = voicings[i + 1]
|
||||
assert len(a) == len(b), (
|
||||
f"Chords {i} and {i+1} have different voice counts: "
|
||||
f"{len(a)} vs {len(b)}"
|
||||
)
|
||||
for j, (pa, pb) in enumerate(zip(a, b)):
|
||||
leap = abs(pb - pa)
|
||||
assert leap <= 4, (
|
||||
f"Voice {j} leaped {leap} semitones "
|
||||
f"({pa}→{pb}) between chord {i} and {i+1}"
|
||||
)
|
||||
|
||||
def test_voice_leading_on_dark_progression(self):
|
||||
"""Voice leading bounds hold for dark emotion too."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
voicings = engine.progression(8, emotion="dark")
|
||||
for i in range(len(voicings) - 1):
|
||||
for pa, pb in zip(voicings[i], voicings[i + 1]):
|
||||
assert abs(pb - pa) <= 4
|
||||
|
||||
def test_voice_leading_on_club_progression(self):
|
||||
"""Voice leading bounds hold for club emotion."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
voicings = engine.progression(8, emotion="club")
|
||||
for i in range(len(voicings) - 1):
|
||||
for pa, pb in zip(voicings[i], voicings[i + 1]):
|
||||
assert abs(pb - pa) <= 4
|
||||
|
||||
def test_voice_leading_on_classic_progression(self):
|
||||
"""Voice leading bounds hold for classic emotion."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
voicings = engine.progression(8, emotion="classic")
|
||||
for i in range(len(voicings) - 1):
|
||||
for pa, pb in zip(voicings[i], voicings[i + 1]):
|
||||
assert abs(pb - pa) <= 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3: Inversions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInversions:
|
||||
"""R3: SHALL support 3 inversion modes."""
|
||||
|
||||
def test_root_inversion_no_change(self):
|
||||
"""Root inversion = identity (notes unchanged)."""
|
||||
engine = ChordEngine("Am", seed=0)
|
||||
voicing = [57, 60, 64] # Am root: A3, C4, E4
|
||||
result = engine._apply_inversion(voicing, "root")
|
||||
assert sorted(result) == sorted(voicing), "Root inversion should not reorder notes"
|
||||
|
||||
def test_first_inversion_bass_is_third(self):
|
||||
"""First inversion: third becomes lowest note."""
|
||||
engine = ChordEngine("Am", seed=0)
|
||||
voicing = [57, 60, 64] # Am root: A=root, C=third, E=fifth
|
||||
result = engine._apply_inversion(voicing, "first")
|
||||
assert min(result) == 60, (
|
||||
f"First inversion bass should be third (60 = C4), got {min(result)}"
|
||||
)
|
||||
|
||||
def test_second_inversion_bass_is_fifth(self):
|
||||
"""Second inversion: fifth becomes lowest note."""
|
||||
engine = ChordEngine("Am", seed=0)
|
||||
voicing = [57, 60, 64]
|
||||
result = engine._apply_inversion(voicing, "second")
|
||||
assert min(result) == 64, (
|
||||
f"Second inversion bass should be fifth (64 = E4), got {min(result)}"
|
||||
)
|
||||
|
||||
def test_first_inversion_on_major_chord(self):
|
||||
"""First inversion works on major chords too."""
|
||||
engine = ChordEngine("C", seed=0)
|
||||
# C major: C=60, E=64, G=67
|
||||
voicing = [60, 64, 67]
|
||||
result = engine._apply_inversion(voicing, "first")
|
||||
assert min(result) == 64, "First inversion of Cmaj: bass = E4 (64)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R4: Emotion divergence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEmotionDivergence:
|
||||
"""R4: MUST support 4 emotion modes with distinct progressions."""
|
||||
|
||||
def test_all_four_distinct(self):
|
||||
"""GIVEN ChordEngine("Am", seed=0) with 4 emotions
|
||||
WHEN progression(8) called per emotion
|
||||
THEN all 4 output sequences differ."""
|
||||
emotions = ["romantic", "dark", "club", "classic"]
|
||||
results = {}
|
||||
for emo in emotions:
|
||||
engine = ChordEngine("Am", seed=0)
|
||||
results[emo] = engine.progression(8, emotion=emo)
|
||||
|
||||
seen = set()
|
||||
for emo, prog in results.items():
|
||||
encoded = str(prog)
|
||||
assert encoded not in seen, (
|
||||
f"Emotion '{emo}' produced duplicate progression"
|
||||
)
|
||||
seen.add(encoded)
|
||||
|
||||
def test_invalid_emotion_falls_back_to_classic(self):
|
||||
"""GIVEN unknown emotion 'angry'
|
||||
WHEN progression(8) called
|
||||
THEN defaults to classic progression, no error."""
|
||||
engine = ChordEngine("Am", seed=0)
|
||||
classic = engine.progression(8, emotion="classic")
|
||||
angry = engine.progression(8, emotion="angry")
|
||||
assert angry == classic, (
|
||||
"Unknown emotion should fall back to classic progression"
|
||||
)
|
||||
|
||||
def test_each_emotion_has_four_degrees(self):
|
||||
"""Each emotion progression must have exactly 4 chord types."""
|
||||
for emo, degrees in EMOTION_PROGRESSIONS.items():
|
||||
assert len(degrees) == 4, (
|
||||
f"Emotion '{emo}' has {len(degrees)} degrees, expected 4"
|
||||
)
|
||||
for degree_offset, quality in degrees:
|
||||
assert isinstance(degree_offset, int), (
|
||||
f"Degree offset must be int, got {type(degree_offset)}"
|
||||
)
|
||||
assert quality in ("maj", "min", "dim", "aug", "7", "m7"), (
|
||||
f"Unknown quality '{quality}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R5: Empty / zero bars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Edge cases from spec requirements."""
|
||||
|
||||
def test_zero_bars_returns_empty(self):
|
||||
"""0 bars → empty list (R5)."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
result = engine.progression(0)
|
||||
assert result == [], "0 bars should return empty list"
|
||||
|
||||
def test_single_bar(self):
|
||||
"""1 bar @ 4 bpc = 1 chord."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
result = engine.progression(1)
|
||||
assert len(result) == 1, f"1 bar = 1 chord, got {len(result)}"
|
||||
|
||||
def test_partial_bar(self):
|
||||
"""3 bars = 3 chords @ 4 bpc."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
result = engine.progression(3)
|
||||
assert len(result) == 3, f"3 bars = 3 chords @ 4 bpc"
|
||||
|
||||
def test_each_chord_is_three_note_triad(self):
|
||||
"""All chords should be 3-note triads (min/maj quality)."""
|
||||
engine = ChordEngine("Am", seed=42)
|
||||
for emotion in ("romantic", "dark", "club", "classic"):
|
||||
voicings = engine.progression(8, emotion=emotion)
|
||||
for i, voicing in enumerate(voicings):
|
||||
assert len(voicing) == 3, (
|
||||
f"{emotion} chord {i}: expected 3 notes, got {len(voicing)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R7: Integration with compose.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComposeIntegration:
|
||||
"""R7: build_chords_track() SHALL delegate to ChordEngine."""
|
||||
|
||||
def test_build_chords_uses_chordengine(self):
|
||||
"""Verify build_chords_track calls ChordEngine.progression."""
|
||||
from unittest.mock import patch
|
||||
from scripts.compose import build_chords_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
with patch("scripts.compose.ChordEngine") as MockEngine:
|
||||
mock_engine = MockEngine.return_value
|
||||
mock_engine.progression.return_value = [[57, 60, 64], [60, 64, 67]]
|
||||
|
||||
track = build_chords_track(
|
||||
sections, offsets, "A", True,
|
||||
emotion="dark", inversion="first",
|
||||
)
|
||||
|
||||
# Verify ChordEngine was instantiated
|
||||
MockEngine.assert_called_once()
|
||||
# Verify progression() was called with correct args
|
||||
mock_engine.progression.assert_called_once_with(
|
||||
4, emotion="dark", beats_per_chord=4, inversion="first",
|
||||
)
|
||||
assert track.name == "Chords"
|
||||
assert len(track.clips) == 1
|
||||
|
||||
def test_compose_cli_emotion_flag_accepted(self, tmp_path):
|
||||
"""CLI --emotion dark generates output (R7 integration)."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
output = tmp_path / "test.rpp"
|
||||
|
||||
with patch("scripts.compose.SampleSelector") as mock_cls:
|
||||
mock_sel = MagicMock()
|
||||
mock_sel._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_sel.select.return_value = [
|
||||
MagicMock(sample={
|
||||
"original_path": "fake_clap.wav",
|
||||
"file_hash": "clap123",
|
||||
}),
|
||||
]
|
||||
mock_sel.select_diverse.return_value = []
|
||||
mock_cls.return_value = mock_sel
|
||||
|
||||
import importlib
|
||||
import scripts.compose as _mod
|
||||
|
||||
orig_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"compose", "--key", "Am", "--emotion", "dark",
|
||||
"--inversion", "root", "--output", str(output),
|
||||
]
|
||||
importlib.reload(_mod)
|
||||
_mod.main()
|
||||
finally:
|
||||
sys.argv = orig_argv
|
||||
|
||||
assert output.exists(), f"Expected {output} to exist"
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent
|
||||
from src.reaper_builder import RPPBuilder
|
||||
from src.composer.drum_analyzer import DrumLoopAnalysis, Transient, BeatGrid
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestDrumloopFirstTracks:
|
||||
def test_all_tracks_created(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
for name in ("Drumloop", "Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
|
||||
for name in ("Drumloop", "808 Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
|
||||
assert name in content, f"Expected track '{name}' in output"
|
||||
|
||||
def test_clap_on_dembow_beats(self, tmp_path):
|
||||
@@ -197,24 +197,48 @@ class TestDrumloopFirstTracks:
|
||||
for s in starts:
|
||||
assert s % 4.0 == 0.0, f"Chord change at beat {s} — should be on downbeat"
|
||||
|
||||
def test_melody_uses_pentatonic(self):
|
||||
from scripts.compose import build_melody_track
|
||||
def test_melody_uses_hook_structure(self):
|
||||
"""Lead melody should use hook-based call-response from melody_engine."""
|
||||
from scripts.compose import build_melody_track, get_pentatonic
|
||||
from src.core.schema import SectionDef
|
||||
from src.composer.melody_engine import _resolve_chord_tones
|
||||
|
||||
sections = [SectionDef(name="chorus", bars=4, energy=1.0)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_melody_track(sections, offsets, "A", True, seed=42)
|
||||
assert len(track.clips) > 0, "Melody should have clips"
|
||||
pitches = {n.pitch for n in track.clips[0].midi_notes}
|
||||
|
||||
notes = track.clips[0].midi_notes
|
||||
pitches = {n.pitch for n in notes}
|
||||
assert len(pitches) > 1, "Melody should use multiple notes"
|
||||
|
||||
# Verify chord-tone emphasis: quarter-position notes should favor chord tones
|
||||
penta = get_pentatonic("A", True, 4) + get_pentatonic("A", True, 5)
|
||||
chord_tones = _resolve_chord_tones("A", True, 0, 4)
|
||||
|
||||
quarter_notes = [n for n in notes if abs(n.start % 1.0) < 0.001]
|
||||
if quarter_notes:
|
||||
chord_count = sum(
|
||||
1 for n in quarter_notes
|
||||
if any(abs(n.pitch - ct) % 12 == 0 for ct in chord_tones)
|
||||
)
|
||||
ratio = chord_count / len(quarter_notes)
|
||||
assert ratio >= 0.5, (
|
||||
f"Hook chord tone ratio {ratio:.1%} below 50%"
|
||||
)
|
||||
|
||||
# Verify notes span the section length
|
||||
max_end = max(n.start + n.duration for n in notes)
|
||||
assert max_end <= 16.0 + 0.001, "Notes should fit within 4 bars"
|
||||
|
||||
def test_master_chain_present(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "Pro-Q" in content, "Expected Pro-Q 3 in master chain"
|
||||
assert "Pro-C" in content, "Expected Pro-C 2 in master chain"
|
||||
assert "Pro-L" in content, "Expected Pro-L 2 in master chain"
|
||||
# After calibration, master chain uses Ozone 12 triplet (with spaces in RPP)
|
||||
assert "Ozone 12" in content or "Pro-Q" in content, (
|
||||
"Expected Ozone 12 or Pro-Q in master chain"
|
||||
)
|
||||
|
||||
def test_sends_wired(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
@@ -243,3 +267,192 @@ class TestBackwardCompat:
|
||||
assert tracks[1].name == "Delay"
|
||||
assert len(tracks[0].plugins) > 0
|
||||
assert len(tracks[1].plugins) > 0
|
||||
|
||||
|
||||
class TestSectionEnergy:
|
||||
"""Integration tests for section energy curve — sparse vs dense sections."""
|
||||
|
||||
def test_section_rename_pre_chorus_not_build(self):
|
||||
"""SECTIONS uses 'pre-chorus' not 'build'."""
|
||||
from scripts.compose import SECTIONS
|
||||
names = {name for name, _, _, _ in SECTIONS}
|
||||
assert "pre-chorus" in names, "Expected 'pre-chorus' section"
|
||||
assert "build" not in names, "'build' must be renamed to 'pre-chorus'"
|
||||
|
||||
def test_intro_has_no_bass(self):
|
||||
"""Intro section should NOT have bass (sparse)."""
|
||||
from scripts.compose import build_bass_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
intro_section = sections[0]
|
||||
assert intro_section.name == "intro"
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
# Find clips whose position matches the intro offset
|
||||
intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0]
|
||||
assert len(intro_positions) == 0, "Intro should have no bass clips"
|
||||
|
||||
def test_intro_has_no_chords(self):
|
||||
"""Intro section should NOT have chords (sparse)."""
|
||||
from scripts.compose import build_chords_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
track = build_chords_track(sections, offsets, "A", True)
|
||||
intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0]
|
||||
assert len(intro_positions) == 0, "Intro should have no chord clips"
|
||||
|
||||
def test_intro_has_no_lead(self):
|
||||
"""Intro section should NOT have lead (sparse)."""
|
||||
from scripts.compose import build_lead_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
track = build_lead_track(sections, offsets, "A", True, seed=42)
|
||||
intro_positions = [c.position for c in track.clips if c.position == offsets[0] * 4.0]
|
||||
assert len(intro_positions) == 0, "Intro should have no lead clips"
|
||||
|
||||
def test_chorus_has_bass_chords_lead(self):
|
||||
"""Chorus section should have bass, chords, and lead (full band)."""
|
||||
from scripts.compose import (
|
||||
build_bass_track, build_chords_track, build_lead_track,
|
||||
build_section_structure,
|
||||
)
|
||||
sections, offsets = build_section_structure()
|
||||
# Find the chorus section index (first "chorus")
|
||||
chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus")
|
||||
chorus_offset = offsets[chorus_idx] * 4.0
|
||||
|
||||
bass = build_bass_track(sections, offsets, "A", True)
|
||||
chords = build_chords_track(sections, offsets, "A", True)
|
||||
lead = build_lead_track(sections, offsets, "A", True, seed=42)
|
||||
|
||||
assert any(c.position == chorus_offset for c in bass.clips), "Chorus should have bass"
|
||||
assert any(c.position == chorus_offset for c in chords.clips), "Chorus should have chords"
|
||||
assert any(c.position == chorus_offset for c in lead.clips), "Chorus should have lead"
|
||||
|
||||
def test_chorus_clips_have_vol_mult(self):
|
||||
"""Clips in chorus sections should have vol_mult set from section."""
|
||||
from scripts.compose import build_drumloop_track, build_bass_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus")
|
||||
chorus_offset = offsets[chorus_idx] * 4.0
|
||||
|
||||
drumloop_track = build_drumloop_track(sections, offsets, seed=0)
|
||||
bass_track = build_bass_track(sections, offsets, "A", True)
|
||||
|
||||
# Audio clips get vol_mult
|
||||
dl_clips = [c for c in drumloop_track.clips if c.position == chorus_offset]
|
||||
if dl_clips:
|
||||
assert dl_clips[0].vol_mult == 1.0, "Chorus drumloop vol_mult should be 1.0"
|
||||
|
||||
# MIDI clips get vol_mult
|
||||
bass_clips = [c for c in bass_track.clips if c.position == chorus_offset]
|
||||
if bass_clips:
|
||||
assert bass_clips[0].vol_mult == 1.0, "Chorus bass vol_mult should be 1.0"
|
||||
|
||||
def test_velocity_scaled_in_intro_vs_chorus(self):
|
||||
"""Verse has lower velocity notes than chorus (velocity_mult 0.7 vs 1.0)."""
|
||||
from scripts.compose import build_bass_track, build_section_structure
|
||||
sections, offsets = build_section_structure()
|
||||
verse_idx = next(i for i, s in enumerate(sections) if s.name == "verse")
|
||||
verse_offset = offsets[verse_idx] * 4.0
|
||||
chorus_idx = next(i for i, s in enumerate(sections) if s.name == "chorus")
|
||||
chorus_offset = offsets[chorus_idx] * 4.0
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
verse_clip = next(c for c in track.clips if c.position == verse_offset)
|
||||
chorus_clip = next(c for c in track.clips if c.position == chorus_offset)
|
||||
|
||||
verse_vel = verse_clip.midi_notes[0].velocity if verse_clip.midi_notes else 0
|
||||
chorus_vel = chorus_clip.midi_notes[0].velocity if chorus_clip.midi_notes else 0
|
||||
assert verse_vel < chorus_vel, \
|
||||
f"Verse velocity ({verse_vel}) should be less than chorus ({chorus_vel})"
|
||||
|
||||
def test_drumloop_assignments_no_break_key(self):
|
||||
"""DRUMLOOP_ASSIGNMENTS has no 'break' key — replaced by activity matrix."""
|
||||
from scripts.compose import DRUMLOOP_ASSIGNMENTS
|
||||
assert "break" not in DRUMLOOP_ASSIGNMENTS
|
||||
assert "pre-chorus" in DRUMLOOP_ASSIGNMENTS
|
||||
|
||||
|
||||
class TestSidechainBassCC:
|
||||
"""Integration tests for CC11 sidechain ducking on 808 bass."""
|
||||
|
||||
def test_bass_track_populates_midi_cc_with_kick_cache(self):
|
||||
"""build_bass_track populates midi_cc when kick cache present."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
kick_cache = {"fake_drumloop.wav": [1.0, 3.0, 5.0, 7.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
assert len(track.clips) > 0, "Bass should have clips"
|
||||
|
||||
verse_clip = track.clips[0]
|
||||
# With 4 kicks in range, each generates 3 CC events
|
||||
assert len(verse_clip.midi_cc) == 12, f"Expected 12 CC events (4 kicks × 3), got {len(verse_clip.midi_cc)}"
|
||||
|
||||
# Check first kick's duck events
|
||||
cc_times = [(cc.time, cc.value) for cc in verse_clip.midi_cc[:3]]
|
||||
assert (1.0, 50) in cc_times, f"Expected CC dip at 1.0, got {cc_times}"
|
||||
assert (1.02, 50) in cc_times, f"Expected CC hold at 1.02, got {cc_times}"
|
||||
assert (1.18, 127) in cc_times, f"Expected CC release at 1.18, got {cc_times}"
|
||||
|
||||
def test_bass_track_no_kick_cache_empty_cc(self):
|
||||
"""build_bass_track produces empty midi_cc when no kick cache provided."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True)
|
||||
assert len(track.clips) > 0
|
||||
verse_clip = track.clips[0]
|
||||
assert verse_clip.midi_cc == [], "midi_cc should be empty without kick cache"
|
||||
|
||||
def test_bass_track_no_kicks_in_range(self):
|
||||
"""build_bass_track produces empty midi_cc when no kicks in clip range."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=2, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
# Kicks at beats far outside the clip range (0-8 beats)
|
||||
kick_cache = {"fake_drumloop.wav": [100.0, 200.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
verse_clip = track.clips[0]
|
||||
assert verse_clip.midi_cc == [], "midi_cc should be empty when kicks are outside clip range"
|
||||
|
||||
def test_bass_track_preserves_notes_with_cc(self):
|
||||
"""build_bass_track preserves existing note generation when CC added."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
sections = [SectionDef(name="verse", bars=4, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [0.0]
|
||||
kick_cache = {"fake_drumloop.wav": [2.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
verse_clip = track.clips[0]
|
||||
# Should still have bass notes (i - iv pattern for 4 bars)
|
||||
assert len(verse_clip.midi_notes) > 0, "Bass notes should still be generated"
|
||||
assert len(verse_clip.midi_cc) == 3, "Should have 3 CC events for 1 kick in range"
|
||||
|
||||
def test_bass_track_kicks_relative_to_clip(self):
|
||||
"""build_bass_track produces CC times relative to clip start, not absolute."""
|
||||
from scripts.compose import build_bass_track
|
||||
from src.core.schema import SectionDef
|
||||
|
||||
# Section at offset 2 bars (8 beats)
|
||||
sections = [SectionDef(name="verse", bars=8, energy=0.5, velocity_mult=0.7)]
|
||||
offsets = [2.0] # starts at bar 2 = beat 8
|
||||
|
||||
# Kick at absolute beat 9.0 → relative beat 1.0
|
||||
kick_cache = {"fake_drumloop.wav": [9.0]}
|
||||
|
||||
track = build_bass_track(sections, offsets, "A", True, kick_cache=kick_cache)
|
||||
verse_clip = track.clips[0]
|
||||
assert len(verse_clip.midi_cc) == 3
|
||||
|
||||
# CC times should be relative to clip start (9.0 - 8.0 = 1.0)
|
||||
first_cc_time = verse_clip.midi_cc[0].time
|
||||
assert first_cc_time == 1.0, f"CC time should be 1.0 (relative), got {first_cc_time}"
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote."""
|
||||
"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote, CCEvent."""
|
||||
|
||||
import dataclasses
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
import pytest
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent
|
||||
|
||||
|
||||
class TestCCEvent:
|
||||
"""Test CCEvent dataclass (Phase 1: schema)."""
|
||||
|
||||
def test_ccevent_round_trip(self):
|
||||
"""CCEvent round-trips correctly through dataclass construction."""
|
||||
evt = CCEvent(controller=11, time=0.5, value=50)
|
||||
assert evt.controller == 11
|
||||
assert evt.time == 0.5
|
||||
assert evt.value == 50
|
||||
|
||||
def test_ccevent_defaults(self):
|
||||
"""CCEvent has no default fields — all are required."""
|
||||
from dataclasses import fields as dc_fields
|
||||
field_names = [f.name for f in dc_fields(CCEvent)]
|
||||
no_default = []
|
||||
for f in dc_fields(CCEvent):
|
||||
if f.default is f.default_factory is dataclasses.MISSING:
|
||||
no_default.append(f.name)
|
||||
assert "controller" in no_default
|
||||
assert "time" in no_default
|
||||
assert "value" in no_default
|
||||
|
||||
def test_clipdef_with_midi_cc(self):
|
||||
"""ClipDef with midi_cc field."""
|
||||
cc = [CCEvent(11, 0.0, 50), CCEvent(11, 0.18, 127)]
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Test", midi_cc=cc)
|
||||
assert len(clip.midi_cc) == 2
|
||||
assert clip.midi_cc[0].controller == 11
|
||||
assert clip.midi_cc[0].time == 0.0
|
||||
assert clip.midi_cc[0].value == 50
|
||||
|
||||
def test_clipdef_midi_cc_default_is_empty(self):
|
||||
"""ClipDef.midi_cc defaults to empty list."""
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Test")
|
||||
assert clip.midi_cc == []
|
||||
|
||||
def test_song_validate_empty_midi_cc(self):
|
||||
"""Song.validate() passes with empty midi_cc (no regression)."""
|
||||
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="Test", clips=[clip])
|
||||
song = SongDefinition(meta=meta, tracks=[track])
|
||||
errors = song.validate()
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestSongDefinitionInstantiation:
|
||||
@@ -135,3 +183,30 @@ class TestMidiNote:
|
||||
"""MidiNote does NOT clamp — accepts any int (caller's responsibility)."""
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200)
|
||||
assert note.velocity == 200
|
||||
|
||||
|
||||
class TestClipDefVolMult:
|
||||
"""Test ClipDef.vol_mult default and behavior."""
|
||||
|
||||
def test_vol_mult_default_is_one(self):
|
||||
"""ClipDef.vol_mult defaults to 1.0."""
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Test")
|
||||
assert clip.vol_mult == 1.0
|
||||
|
||||
def test_vol_mult_custom_value(self):
|
||||
"""ClipDef.vol_mult accepts custom value."""
|
||||
clip = ClipDef(position=0.0, length=16.0, name="Test", vol_mult=0.7)
|
||||
assert clip.vol_mult == 0.7
|
||||
|
||||
def test_audio_clip_vol_mult_default_is_one(self):
|
||||
"""Audio clip with default vol_mult=1.0 has no D_VOL side effect."""
|
||||
clip = ClipDef(position=0.0, length=16.0, audio_path="test.wav", vol_mult=1.0)
|
||||
assert clip.is_audio
|
||||
assert clip.vol_mult == 1.0
|
||||
|
||||
def test_midi_clip_vol_mult_default_is_one(self):
|
||||
"""MIDI clip with default vol_mult=1.0 has no velocity scaling."""
|
||||
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100)
|
||||
clip = ClipDef(position=0.0, length=16.0, midi_notes=[note], vol_mult=1.0)
|
||||
assert clip.is_midi
|
||||
assert clip.vol_mult == 1.0
|
||||
|
||||
448
tests/test_melody_engine.py
Normal file
448
tests/test_melody_engine.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""Unit tests for src/composer/melody_engine.py — hook-based melody generation."""
|
||||
|
||||
import pytest
|
||||
from src.composer.melody_engine import (
|
||||
build_motif,
|
||||
apply_variation,
|
||||
build_call_response,
|
||||
_resolve_chord_tones,
|
||||
_resolve_tension_notes,
|
||||
_resolve_tonic,
|
||||
_get_pentatonic,
|
||||
_get_diatonic,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.1 — Determinism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMotifDeterministic:
|
||||
"""Same seed → identical output."""
|
||||
|
||||
def test_motif_deterministic_hook(self):
|
||||
a = build_motif("A", True, "hook", 4, 42)
|
||||
b = build_motif("A", True, "hook", 4, 42)
|
||||
assert a == b, "Same seed must produce identical motfs"
|
||||
assert len(a) > 0, "Hook should produce notes"
|
||||
|
||||
def test_motif_deterministic_stabs(self):
|
||||
a = build_motif("A", True, "stabs", 2, 1)
|
||||
b = build_motif("A", True, "stabs", 2, 1)
|
||||
assert a == b
|
||||
|
||||
def test_motif_deterministic_smooth(self):
|
||||
a = build_motif("A", True, "smooth", 4, 7)
|
||||
b = build_motif("A", True, "smooth", 4, 7)
|
||||
assert a == b
|
||||
|
||||
def test_motif_deterministic_different_style(self):
|
||||
hook = build_motif("A", True, "hook", 4, 42)
|
||||
stabs = build_motif("A", True, "stabs", 4, 42)
|
||||
assert hook != stabs, "Different styles should produce different output"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.2 — Different seeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMotifDifferentSeeds:
|
||||
"""Different seeds → different output."""
|
||||
|
||||
def test_motif_different_seeds_hook(self):
|
||||
a = build_motif("A", True, "hook", 4, 42)
|
||||
b = build_motif("A", True, "hook", 4, 99)
|
||||
assert a != b, "Different seeds must produce different output"
|
||||
|
||||
def test_motif_different_seeds_stabs(self):
|
||||
a = build_motif("A", True, "stabs", 4, 1)
|
||||
b = build_motif("A", True, "stabs", 4, 2)
|
||||
assert a != b
|
||||
|
||||
def test_motif_different_seeds_smooth(self):
|
||||
a = build_motif("A", True, "smooth", 4, 1)
|
||||
b = build_motif("A", True, "smooth", 4, 2)
|
||||
assert a != b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.3 — Invalid style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInvalidStyle:
|
||||
"""Invalid style raises ValueError."""
|
||||
|
||||
def test_invalid_style_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Invalid style"):
|
||||
build_motif("A", True, "invalid", 4, 42)
|
||||
|
||||
def test_value_error_mentions_valid_styles(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
build_motif("A", True, "xyz", 4, 42)
|
||||
msg = str(exc.value)
|
||||
assert "hook" in msg
|
||||
assert "stabs" in msg
|
||||
assert "smooth" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.4 — Chord tones on strong beats (hook)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHookChordTones:
|
||||
"""Hook style: ≥70% of quarter-position notes are chord tones."""
|
||||
|
||||
@staticmethod
|
||||
def _quarter_position_notes(notes):
|
||||
"""Return notes whose start time is on a quarter-beat boundary."""
|
||||
return [n for n in notes if abs(n.start % 1.0) < 0.001]
|
||||
|
||||
@staticmethod
|
||||
def _is_chord_tone(pitch, key_root, key_minor, bar, bar_offset=0):
|
||||
"""Check if pitch belongs to the active chord at the given bar."""
|
||||
chord_tones = _resolve_chord_tones(key_root, key_minor, bar)
|
||||
return any(abs(pitch - ct) % 12 == 0 for ct in chord_tones)
|
||||
|
||||
def test_hook_chord_tones_on_strong_beats(self):
|
||||
"""≥70% of notes on quarter positions are chord tones."""
|
||||
notes = build_motif("A", True, "hook", 8, 42)
|
||||
quarter_notes = self._quarter_position_notes(notes)
|
||||
assert len(quarter_notes) > 0, "Hook must have notes on quarter positions"
|
||||
|
||||
chord_count = 0
|
||||
for note in quarter_notes:
|
||||
bar = int(note.start // 4.0)
|
||||
if self._is_chord_tone(note.pitch, "A", True, bar):
|
||||
chord_count += 1
|
||||
|
||||
ratio = chord_count / len(quarter_notes)
|
||||
assert ratio >= 0.70, (
|
||||
f"Chord tone ratio on strong beats: {ratio:.1%}, need ≥ 70%\n"
|
||||
f"Pitches: {[n.pitch for n in quarter_notes]}"
|
||||
)
|
||||
|
||||
def test_hook_produces_notes(self):
|
||||
"""Hook should produce a reasonable number of notes."""
|
||||
notes = build_motif("A", True, "hook", 4, 42)
|
||||
assert 16 <= len(notes) <= 24, (
|
||||
f"Expected 16-24 notes for 4-bar hook, got {len(notes)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.5 — Stabs grid alignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStabsGridAlignment:
|
||||
"""Stabs: all notes on dembow positions [1.0, 2.5, 3.0, 3.5] per bar."""
|
||||
|
||||
DEMBOW = {1.0, 2.5, 3.0, 3.5}
|
||||
|
||||
def test_stabs_grid_alignment(self):
|
||||
notes = build_motif("A", True, "stabs", 4, 1)
|
||||
assert len(notes) > 0, "Stabs must produce notes"
|
||||
|
||||
for note in notes:
|
||||
bar_start = int(note.start // 4.0) * 4.0
|
||||
pos_in_bar = note.start - bar_start
|
||||
# Allow tiny floating-point tolerance
|
||||
assert any(
|
||||
abs(pos_in_bar - dp) < 0.001 for dp in self.DEMBOW
|
||||
), f"Note at {note.start} (pos_in_bar={pos_in_bar}) not on dembow grid"
|
||||
|
||||
def test_stabs_duration_16th(self):
|
||||
"""All stabs should be 16th notes (≤ 0.25 beats)."""
|
||||
notes = build_motif("A", True, "stabs", 4, 1)
|
||||
for note in notes:
|
||||
assert note.duration <= 0.25, (
|
||||
f"Stab duration {note.duration} > 16th note"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.6 — Smooth stepwise motion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSmoothStepwise:
|
||||
"""Smooth style: consecutive notes differ by ≤ 2 semitones."""
|
||||
|
||||
def test_smooth_stepwise_motion(self):
|
||||
notes = build_motif("A", True, "smooth", 4, 7)
|
||||
assert len(notes) >= 8, f"Expected at least 8 notes, got {len(notes)}"
|
||||
|
||||
sorted_notes = sorted(notes, key=lambda n: n.start)
|
||||
for i in range(len(sorted_notes) - 1):
|
||||
diff = abs(sorted_notes[i + 1].pitch - sorted_notes[i].pitch)
|
||||
assert diff <= 2, (
|
||||
f"Step from pitch {sorted_notes[i].pitch} to {sorted_notes[i + 1].pitch} "
|
||||
f"at beat {sorted_notes[i + 1].start}: diff={diff} > 2"
|
||||
)
|
||||
|
||||
def test_smooth_eighth_note_density(self):
|
||||
"""Smooth style should produce notes at roughly eighth-note spacing."""
|
||||
notes = build_motif("A", True, "smooth", 4, 7)
|
||||
sorted_notes = sorted(notes, key=lambda n: n.start)
|
||||
|
||||
# Each note should be ~0.5 beats apart (eighth note)
|
||||
# Check that most gaps are close to 0.5
|
||||
gaps = []
|
||||
for i in range(len(sorted_notes) - 1):
|
||||
gap = sorted_notes[i + 1].start - sorted_notes[i].start
|
||||
gaps.append(gap)
|
||||
|
||||
avg_gap = sum(gaps) / len(gaps) if gaps else 0
|
||||
assert 0.4 < avg_gap < 0.6, (
|
||||
f"Expected eighth-note spacing (~0.5), got avg gap {avg_gap:.3f}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.7 — Variation preserves structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVariation:
|
||||
"""apply_variation() preserves note count, durations, and IOIs."""
|
||||
|
||||
def test_variation_preserves_note_count(self):
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
variant = apply_variation(motif, shift_beats=0.25)
|
||||
assert len(variant) == len(motif)
|
||||
|
||||
def test_variation_preserves_durations(self):
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
variant = apply_variation(motif, shift_beats=0.25, transpose_semitones=3)
|
||||
for orig, var in zip(motif, variant):
|
||||
assert var.duration == orig.duration, (
|
||||
f"Duration mismatch: {var.duration} != {orig.duration}"
|
||||
)
|
||||
|
||||
def test_variation_preserves_iois(self):
|
||||
"""Inter-onset intervals are preserved after shift."""
|
||||
motif = sorted(build_motif("A", True, "hook", 4, 42), key=lambda n: n.start)
|
||||
variant = sorted(
|
||||
apply_variation(motif, shift_beats=0.25),
|
||||
key=lambda n: n.start,
|
||||
)
|
||||
|
||||
for i in range(len(motif) - 1):
|
||||
orig_ioi = motif[i + 1].start - motif[i].start
|
||||
var_ioi = variant[i + 1].start - variant[i].start
|
||||
assert abs(orig_ioi - var_ioi) < 0.001, (
|
||||
f"IOI mismatch at index {i}: {var_ioi:.4f} != {orig_ioi:.4f}"
|
||||
)
|
||||
|
||||
def test_variation_shifts_start_times(self):
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
variant = apply_variation(motif, shift_beats=0.25)
|
||||
for orig, var in zip(
|
||||
sorted(motif, key=lambda n: n.start),
|
||||
sorted(variant, key=lambda n: n.start),
|
||||
):
|
||||
assert abs(var.start - orig.start - 0.25) < 0.001
|
||||
|
||||
def test_variation_transposes_pitches(self):
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
variant = apply_variation(motif, transpose_semitones=3)
|
||||
for orig, var in zip(motif, variant):
|
||||
assert var.pitch == orig.pitch + 3
|
||||
|
||||
def test_variation_empty_motif(self):
|
||||
result = apply_variation([], shift_beats=1.0)
|
||||
assert result == []
|
||||
|
||||
def test_variation_defaults(self):
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
variant = apply_variation(motif)
|
||||
assert len(variant) == len(motif)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.8 — Call ends on tension, response on tonic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCallResponseResolution:
|
||||
"""build_call_response(): call → V/VII, response → tonic."""
|
||||
|
||||
def test_call_ends_on_tension_response_ends_on_tonic(self):
|
||||
"""Call (first half) last note = V or VII; response last note = tonic."""
|
||||
# Am: tonic = A(69), V = E(76), VII = G(79)
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
result = build_call_response(motif, bars=8, key_root="A", key_minor=True, seed=42)
|
||||
assert len(result) > 0
|
||||
|
||||
# Sort by start time
|
||||
sorted_notes = sorted(result, key=lambda n: n.start)
|
||||
|
||||
# Find last note of first 4 bars (call)
|
||||
call_cutoff = 4.0 * 4 # 4 bars * 4 beats
|
||||
call_notes = [n for n in sorted_notes if n.start < call_cutoff]
|
||||
assert len(call_notes) > 0, "No notes in call half"
|
||||
last_call_pitch = call_notes[-1].pitch % 12
|
||||
|
||||
# V of Am is E (pitch%12=4), VII is G (pitch%12=7)
|
||||
assert last_call_pitch in (4, 7), (
|
||||
f"Last call note pitch class {last_call_pitch} must be V(4=E) or VII(7=G)"
|
||||
)
|
||||
|
||||
# Last note overall (response) must be tonic A (pitch%12=9)
|
||||
last_note = sorted_notes[-1].pitch % 12
|
||||
assert last_note == 9, (
|
||||
f"Last note pitch class {last_note} must be tonic A(9)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.9 — Call-response fills bars with motif repetition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCallResponseFillsBars:
|
||||
"""build_call_response() fills section with motif repetition."""
|
||||
|
||||
def test_call_response_fills_bars(self):
|
||||
"""A 2-bar motif repeated to fill 8 bars."""
|
||||
motif = build_motif("A", True, "hook", 2, 42)
|
||||
result = build_call_response(motif, bars=8, key_root="A", key_minor=True, seed=42)
|
||||
|
||||
# Total span should be ~8 bars (32 beats)
|
||||
max_end = max(n.start + n.duration for n in result) if result else 0
|
||||
min_start = min(n.start for n in result) if result else 0
|
||||
span = max_end - min_start
|
||||
assert span >= 28, f"Notes should span ~32 beats (8 bars), got {span}"
|
||||
|
||||
# Motif content should repeat at least 2 times within 8 bars
|
||||
assert len(result) >= len(motif) * 2, (
|
||||
f"Motif repeats: expected ≥{len(motif)*2} notes, got {len(result)}"
|
||||
)
|
||||
|
||||
def test_call_response_empty_motif(self):
|
||||
result = build_call_response([], bars=8, key_root="A", key_minor=True)
|
||||
assert result == []
|
||||
|
||||
def test_call_response_length_matches_bars(self):
|
||||
"""Result should not exceed `bars` worth of material."""
|
||||
motif = build_motif("A", True, "hook", 4, 42)
|
||||
for test_bars in (2, 4, 8):
|
||||
result = build_call_response(motif, bars=test_bars,
|
||||
key_root="A", key_minor=True, seed=42)
|
||||
max_end = max((n.start + n.duration for n in result), default=0)
|
||||
assert max_end <= test_bars * 4.0 + 0.001, (
|
||||
f"For {test_bars} bars, max_end={max_end} exceeds {test_bars * 4.0}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveChordTones:
|
||||
"""_resolve_chord_tones returns correct pitches."""
|
||||
|
||||
def test_chord_tones_am_bar0(self):
|
||||
"""Bar 0 of Am should return Am chord tones (A, C, E)."""
|
||||
tones = _resolve_chord_tones("A", True, 0, 4)
|
||||
# Check for pitch classes 9(A), 0(C), 4(E)
|
||||
pitch_classes = {p % 12 for p in tones}
|
||||
assert 9 in pitch_classes, "A missing"
|
||||
assert 0 in pitch_classes, "C missing"
|
||||
assert 4 in pitch_classes, "E missing"
|
||||
|
||||
def test_chord_tones_am_bar2(self):
|
||||
"""Bar 2 of Am should return F major (F, A, C) — the VI chord."""
|
||||
tones = _resolve_chord_tones("A", True, 2, 4)
|
||||
pitch_classes = {p % 12 for p in tones}
|
||||
assert 5 in pitch_classes, "F missing" # F = 5
|
||||
assert 9 in pitch_classes, "A missing" # A = 9
|
||||
assert 0 in pitch_classes, "C missing" # C = 0
|
||||
|
||||
def test_chord_tones_wraps(self):
|
||||
"""Bar 8 wraps back to chord i."""
|
||||
tones0 = _resolve_chord_tones("A", True, 0, 4)
|
||||
tones8 = _resolve_chord_tones("A", True, 8, 4)
|
||||
p0 = {p % 12 for p in tones0}
|
||||
p8 = {p % 12 for p in tones8}
|
||||
assert p0 == p8, "Bar 0 and bar 8 should have same chord tones (wrapped)"
|
||||
|
||||
|
||||
class TestResolveTensionNotes:
|
||||
"""_resolve_tension_notes returns correct V and VII."""
|
||||
|
||||
def test_tension_notes_am(self):
|
||||
v_pitch, vii_pitch = _resolve_tension_notes("A", True, 4)
|
||||
# V of A = E (MIDI 69 + 7 = 76)
|
||||
assert v_pitch == 76, f"V of Am should be E (76), got {v_pitch}"
|
||||
# VII of Am minor = G (MIDI 69 + 10 = 79)
|
||||
assert vii_pitch == 79, f"VII of Am should be G (79), got {vii_pitch}"
|
||||
|
||||
def test_tension_notes_cm(self):
|
||||
v_pitch, vii_pitch = _resolve_tension_notes("C", True, 4)
|
||||
# C4=60, V=G4=60+7=67
|
||||
assert v_pitch == 67, f"V of Cm should be G4 (67), got {v_pitch}"
|
||||
# VII of C minor = Bb4 = 60+10=70
|
||||
assert vii_pitch == 70, f"VII of Cm should be Bb4 (70), got {vii_pitch}"
|
||||
|
||||
|
||||
class TestResolveTonic:
|
||||
"""_resolve_tonic returns correct pitch."""
|
||||
|
||||
def test_tonic_am(self):
|
||||
assert _resolve_tonic("A", 4) == 69 # A4
|
||||
|
||||
def test_tonic_dm(self):
|
||||
assert _resolve_tonic("D", 4) == 62 # D4
|
||||
|
||||
|
||||
class TestScaleHelpers:
|
||||
"""_get_pentatonic and _get_diatonic."""
|
||||
|
||||
def test_pentatonic_am(self):
|
||||
notes = _get_pentatonic("A", True, 4)
|
||||
pitch_classes = {n % 12 for n in notes}
|
||||
assert pitch_classes == {9, 0, 2, 4, 7}, (
|
||||
f"Am pentatonic: A C D E G, got {pitch_classes}"
|
||||
)
|
||||
|
||||
def test_pentatonic_c_major(self):
|
||||
notes = _get_pentatonic("C", False, 4)
|
||||
pitch_classes = {n % 12 for n in notes}
|
||||
assert pitch_classes == {0, 2, 4, 7, 9}, (
|
||||
f"C major pentatonic: C D E G A, got {pitch_classes}"
|
||||
)
|
||||
|
||||
def test_diatonic_am(self):
|
||||
notes = _get_diatonic("A", True, 4)
|
||||
pitch_classes = {n % 12 for n in notes}
|
||||
assert pitch_classes == {9, 11, 0, 2, 4, 5, 7}, (
|
||||
f"Am natural minor: A B C D E F G, got {pitch_classes}"
|
||||
)
|
||||
|
||||
def test_diatonic_c_major(self):
|
||||
notes = _get_diatonic("C", False, 4)
|
||||
pitch_classes = {n % 12 for n in notes}
|
||||
assert pitch_classes == {0, 2, 4, 5, 7, 9, 11}, (
|
||||
f"C major: C D E F G A B, got {pitch_classes}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-style tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrossStyle:
|
||||
"""Tests covering all three styles."""
|
||||
|
||||
def test_all_styles_return_midi_notes(self):
|
||||
for style in ("hook", "stabs", "smooth"):
|
||||
notes = build_motif("A", True, style, 4, 0)
|
||||
assert isinstance(notes, list)
|
||||
assert len(notes) > 0, f"Style '{style}' returned empty list"
|
||||
assert all(hasattr(n, "pitch") for n in notes)
|
||||
assert all(hasattr(n, "start") for n in notes)
|
||||
|
||||
def test_different_key_produces_different_output(self):
|
||||
am = build_motif("A", True, "hook", 4, 42)
|
||||
dm = build_motif("D", True, "hook", 4, 42)
|
||||
assert am != dm, "Different keys should produce different motifs"
|
||||
|
||||
def test_major_key_produces_notes(self):
|
||||
notes = build_motif("C", False, "hook", 4, 42)
|
||||
assert len(notes) > 0
|
||||
194
tests/test_preset_transform.py
Normal file
194
tests/test_preset_transform.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Tests for role-aware preset system (presets-pack).
|
||||
|
||||
Covers PresetTransformer, role-aware PLUGIN_PRESETS lookup,
|
||||
and thread-through via make_plugin → PluginDef.role → _build_plugin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.reaper_builder.preset_transformer import PresetTransformer
|
||||
from src.reaper_builder import PLUGIN_PRESETS, PLUGIN_REGISTRY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PresetTransformer unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPresetTransformer:
|
||||
"""PresetTransformer derives role-specific preset data."""
|
||||
|
||||
def test_derive_serum_returns_list(self):
|
||||
"""derive() for Serum_2 returns a list of chunks."""
|
||||
default = PLUGIN_PRESETS.get(("Serum_2", ""))
|
||||
assert default is not None, "Serum_2 default preset must exist"
|
||||
result = PresetTransformer.derive("Serum_2", default, "bass")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == len(default)
|
||||
assert all(isinstance(c, str) for c in result)
|
||||
|
||||
def test_derive_decapitator_returns_list(self):
|
||||
"""derive() for Decapitator returns a list of chunks."""
|
||||
default = PLUGIN_PRESETS.get(("Decapitator", ""))
|
||||
assert default is not None, "Decapitator default preset must exist"
|
||||
result = PresetTransformer.derive("Decapitator", default, "drums")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == len(default)
|
||||
|
||||
def test_derive_omnisphere_returns_list(self):
|
||||
"""derive() for Omnisphere returns a list of chunks."""
|
||||
default = PLUGIN_PRESETS.get(("Omnisphere", ""))
|
||||
assert default is not None, "Omnisphere default preset must exist"
|
||||
result = PresetTransformer.derive("Omnisphere", default, "pad")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == len(default)
|
||||
|
||||
def test_derive_unknown_plugin_returns_default(self):
|
||||
"""derive() for unsupported plugin returns original chunks."""
|
||||
chunks = ["mock_chunk_1", "mock_chunk_2"]
|
||||
result = PresetTransformer.derive("NonexistentPlugin", chunks, "lead")
|
||||
assert result == chunks
|
||||
|
||||
def test_derive_preserves_chunk_count(self):
|
||||
"""derive() output has same number of chunks as input."""
|
||||
for plugin in ["Serum_2", "Decapitator", "Omnisphere"]:
|
||||
default = PLUGIN_PRESETS.get((plugin, ""))
|
||||
if not default:
|
||||
continue
|
||||
for role in ["bass", "lead", "drums", "pad"]:
|
||||
result = PresetTransformer.derive(plugin, default, role)
|
||||
assert len(result) == len(default), (
|
||||
f"{plugin}/{role} chunk count mismatch: "
|
||||
f"got {len(result)}, expected {len(default)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role-aware preset structure tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoleAwarePresets:
|
||||
"""PLUGIN_PRESETS is structured as {(plugin, role): chunks}."""
|
||||
|
||||
def test_default_role_entries_exist(self):
|
||||
"""All known plugins have a "" (default) role entry."""
|
||||
flat_keys = set()
|
||||
for (name, role), _ in PLUGIN_PRESETS.items():
|
||||
if role == "":
|
||||
flat_keys.add(name)
|
||||
# At minimum, the multi-role targets must have defaults
|
||||
for name in ["Serum_2", "Decapitator", "Omnisphere"]:
|
||||
assert name in flat_keys, f"{name} must have default role entry"
|
||||
|
||||
def test_role_specific_entries_exist(self):
|
||||
"""Multi-role plugins have their role-specific entries."""
|
||||
roles = {
|
||||
"Serum_2": ["bass", "lead"],
|
||||
"Decapitator": ["drumloop", "bass", "clap", "perc"],
|
||||
"Omnisphere": ["chords", "pad"],
|
||||
}
|
||||
for plugin, expected_roles in roles.items():
|
||||
for role in expected_roles:
|
||||
assert (plugin, role) in PLUGIN_PRESETS, (
|
||||
f"Missing ({plugin}, {role}) in PLUGIN_PRESETS"
|
||||
)
|
||||
|
||||
def test_role_entries_non_empty(self):
|
||||
"""Role-specific entries contain non-empty preset data."""
|
||||
for (name, role), chunks in PLUGIN_PRESETS.items():
|
||||
if role != "" and name in ("Serum_2", "Decapitator", "Omnisphere"):
|
||||
assert len(chunks) > 0, (
|
||||
f"({name}, {role}) has empty preset data"
|
||||
)
|
||||
|
||||
def test_unknown_role_falls_back_to_default(self):
|
||||
"""Role not present in PLUGIN_PRESETS → fall back to default."""
|
||||
# Simulate: a plugin has only default entry, no "pad" role
|
||||
# The lookup should return the "" entry
|
||||
from src.reaper_builder import _resolve_preset
|
||||
|
||||
# Gullfoss_Master doesn't have multi-role entries, only ""
|
||||
result = _resolve_preset("Gullfoss_Master", "pad")
|
||||
default = PLUGIN_PRESETS.get(("Gullfoss_Master", ""))
|
||||
assert result == default, (
|
||||
"Unknown role should fall back to default preset"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: make_plugin + role threading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMakePluginRoleThreading:
|
||||
"""make_plugin with role correctly sets PluginDef.role and preset_data."""
|
||||
|
||||
def test_make_plugin_with_role_sets_role_field(self):
|
||||
"""make_plugin(key, idx, role=...) sets PluginDef.role."""
|
||||
from scripts.compose import make_plugin
|
||||
|
||||
p = make_plugin("Serum_2", 0, role="bass")
|
||||
assert p.role == "bass"
|
||||
assert p.preset_data is not None
|
||||
|
||||
def test_make_plugin_without_role_defaults_to_empty(self):
|
||||
"""make_plugin(key, idx) without role sets role="" (backward compat)."""
|
||||
from scripts.compose import make_plugin
|
||||
|
||||
p = make_plugin("Decapitator", 0)
|
||||
assert p.role == ""
|
||||
|
||||
def test_make_plugin_different_roles_lookup_correct_entries(self):
|
||||
"""Different roles resolve to their respective PLUGIN_PRESETS entries."""
|
||||
from scripts.compose import make_plugin
|
||||
|
||||
# Both should return data — in MVP they're identical but structure is correct
|
||||
p_bass = make_plugin("Serum_2", 0, role="bass")
|
||||
p_lead = make_plugin("Serum_2", 0, role="lead")
|
||||
|
||||
# Both should have non-None preset data
|
||||
assert p_bass.preset_data is not None, "bass role should have preset_data"
|
||||
assert p_lead.preset_data is not None, "lead role should have preset_data"
|
||||
|
||||
# Both should be lists with content
|
||||
assert isinstance(p_bass.preset_data, list)
|
||||
assert isinstance(p_lead.preset_data, list)
|
||||
assert len(p_bass.preset_data) > 0
|
||||
assert len(p_lead.preset_data) > 0
|
||||
|
||||
def test_make_plugin_unknown_role_falls_back(self):
|
||||
"""Unknown role returns preset from "" entry (if available)."""
|
||||
from scripts.compose import make_plugin
|
||||
|
||||
# "pad" role is not valid for Serum_2 (Omnisphere handles pad)
|
||||
p = make_plugin("Serum_2", 0, role="pad")
|
||||
# Should still get the default Serum_2 preset
|
||||
assert p.preset_data is not None
|
||||
assert p.role == "pad"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Existing behavior preserved with no role."""
|
||||
|
||||
def test_make_plugin_known_key_still_works(self):
|
||||
"""make_plugin with known registry key (no role) works as before."""
|
||||
from scripts.compose import make_plugin
|
||||
p = make_plugin("Decapitator", 0)
|
||||
assert p.name == "Decapitator"
|
||||
assert p.index == 0
|
||||
assert p.role == ""
|
||||
|
||||
def test_make_plugin_unknown_key_still_works(self):
|
||||
"""make_plugin with unknown key (no role) returns PluginDef."""
|
||||
from scripts.compose import make_plugin
|
||||
p = make_plugin("NonExistent", 2)
|
||||
assert p.name == "NonExistent"
|
||||
assert p.index == 2
|
||||
assert p.role == ""
|
||||
@@ -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)
|
||||
|
||||
@@ -33,6 +33,152 @@ class TestSectionDef:
|
||||
assert section.vol_mult == 0.6
|
||||
|
||||
|
||||
class TestBuildSectionStructureMultipliers:
|
||||
"""Verify build_section_structure() populates velocity_mult and vol_mult."""
|
||||
|
||||
@staticmethod
|
||||
def _get_sections():
|
||||
from scripts.compose import build_section_structure
|
||||
sections, _offsets = build_section_structure()
|
||||
return {s.name: s for s in sections}
|
||||
|
||||
def test_intro_has_low_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["intro"].velocity_mult == 0.6
|
||||
assert sections["intro"].vol_mult == 0.70
|
||||
|
||||
def test_verse_has_mid_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["verse"].velocity_mult == 0.7
|
||||
assert sections["verse"].vol_mult == 0.85
|
||||
|
||||
def test_pre_chorus_has_high_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["pre-chorus"].velocity_mult == 0.85
|
||||
assert sections["pre-chorus"].vol_mult == 0.95
|
||||
|
||||
def test_chorus_has_full_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["chorus"].velocity_mult == 1.0
|
||||
assert sections["chorus"].vol_mult == 1.0
|
||||
|
||||
def test_verse2_same_as_verse(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["verse2"].velocity_mult == 0.7
|
||||
assert sections["verse2"].vol_mult == 0.85
|
||||
|
||||
def test_chorus2_same_as_chorus(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["chorus2"].velocity_mult == 1.0
|
||||
assert sections["chorus2"].vol_mult == 1.0
|
||||
|
||||
def test_bridge_has_low_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["bridge"].velocity_mult == 0.6
|
||||
assert sections["bridge"].vol_mult == 0.75
|
||||
|
||||
def test_final_has_full_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["final"].velocity_mult == 1.0
|
||||
assert sections["final"].vol_mult == 1.0
|
||||
|
||||
def test_outro_has_lowest_multipliers(self):
|
||||
sections = self._get_sections()
|
||||
assert sections["outro"].velocity_mult == 0.4
|
||||
assert sections["outro"].vol_mult == 0.60
|
||||
|
||||
def test_all_sections_have_multipliers(self):
|
||||
"""Every section name in SECTIONS has a corresponding entry in multipliers."""
|
||||
sections = self._get_sections()
|
||||
from scripts.compose import SECTIONS
|
||||
expected_names = {name for name, _, _, _ in SECTIONS}
|
||||
assert set(sections.keys()) == expected_names
|
||||
|
||||
|
||||
class TestSectionActiveHelper:
|
||||
"""Tests for _section_active() centralized activity helper."""
|
||||
|
||||
@staticmethod
|
||||
def _get_activity():
|
||||
from scripts.compose import TRACK_ACTIVITY
|
||||
return TRACK_ACTIVITY
|
||||
|
||||
def test_intro_drumloop_active(self):
|
||||
from scripts.compose import _section_active
|
||||
assert _section_active("intro", "drumloop", self._get_activity()) is True
|
||||
|
||||
def test_intro_bass_inactive(self):
|
||||
from scripts.compose import _section_active
|
||||
assert _section_active("intro", "bass", self._get_activity()) is False
|
||||
|
||||
def test_intro_chords_inactive(self):
|
||||
from scripts.compose import _section_active
|
||||
assert _section_active("intro", "chords", self._get_activity()) is False
|
||||
|
||||
def test_intro_lead_inactive(self):
|
||||
from scripts.compose import _section_active
|
||||
assert _section_active("intro", "lead", self._get_activity()) is False
|
||||
|
||||
def test_chorus_all_active(self):
|
||||
from scripts.compose import _section_active
|
||||
activity = self._get_activity()
|
||||
for role in ("drumloop", "perc", "bass", "chords", "lead", "clap", "pad"):
|
||||
assert _section_active("chorus", role, activity) is True, f"chorus.{role} should be active"
|
||||
|
||||
def test_verse_only_drumloop_bass_chords_active(self):
|
||||
from scripts.compose import _section_active
|
||||
activity = self._get_activity()
|
||||
assert _section_active("verse", "drumloop", activity) is True
|
||||
assert _section_active("verse", "bass", activity) is True
|
||||
assert _section_active("verse", "chords", activity) is True
|
||||
assert _section_active("verse", "lead", activity) is False
|
||||
assert _section_active("verse", "perc", activity) is False
|
||||
assert _section_active("verse", "clap", activity) is False
|
||||
|
||||
def test_pre_chorus_section_name(self):
|
||||
"""The section is named pre-chorus, not build."""
|
||||
from scripts.compose import _section_active
|
||||
activity = self._get_activity()
|
||||
assert "pre-chorus" in activity, "pre-chorus must be a key in TRACK_ACTIVITY"
|
||||
assert "build" not in activity, "build must NOT be a key in TRACK_ACTIVITY"
|
||||
assert _section_active("pre-chorus", "bass", activity) is True
|
||||
|
||||
def test_unknown_section_returns_false(self):
|
||||
from scripts.compose import _section_active
|
||||
assert _section_active("xyz", "bass", self._get_activity()) is False
|
||||
|
||||
def test_unknown_role_returns_false(self):
|
||||
from scripts.compose import _section_active
|
||||
assert _section_active("chorus", "banjo", self._get_activity()) is False
|
||||
|
||||
def test_outro_has_drumloop_and_pad(self):
|
||||
from scripts.compose import _section_active
|
||||
activity = self._get_activity()
|
||||
assert _section_active("outro", "drumloop", activity) is True
|
||||
assert _section_active("outro", "pad", activity) is True
|
||||
assert _section_active("outro", "bass", activity) is False
|
||||
|
||||
def test_bridge_has_drumloop_pad_lead(self):
|
||||
from scripts.compose import _section_active
|
||||
activity = self._get_activity()
|
||||
assert _section_active("bridge", "drumloop", activity) is True
|
||||
assert _section_active("bridge", "pad", activity) is True
|
||||
assert _section_active("bridge", "lead", activity) is True
|
||||
assert _section_active("bridge", "bass", activity) is False
|
||||
assert _section_active("bridge", "chords", activity) is False
|
||||
|
||||
def test_final_has_perc_bass_chords_lead_clap_pad(self):
|
||||
from scripts.compose import _section_active
|
||||
activity = self._get_activity()
|
||||
assert _section_active("final", "drumloop", activity) is True
|
||||
assert _section_active("final", "perc", activity) is True
|
||||
assert _section_active("final", "bass", activity) is True
|
||||
assert _section_active("final", "chords", activity) is True
|
||||
assert _section_active("final", "lead", activity) is True
|
||||
assert _section_active("final", "clap", activity) is True
|
||||
assert _section_active("final", "pad", activity) is True
|
||||
|
||||
|
||||
class TestPluginRegistry:
|
||||
def test_plugins_in_registry(self):
|
||||
from src.reaper_builder import PLUGIN_REGISTRY
|
||||
|
||||
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