- 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.
632 lines
23 KiB
Python
632 lines
23 KiB
Python
"""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
|