"""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 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 # Master chain upgraded assert song.master_plugins == [ "Ozone_12_Equalizer", "Ozone_12_Dynamics", "Ozone_12_Maximizer", ] def test_apply_skips_bass_volume(self): """Bass track should get correct calibrated volume, not EQ.""" 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] assert bass.volume == 0.82 # --------------------------------------------------------------------------- # 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