Files
reaper-control/tests/test_calibrator.py
renato97 623af69483 fix: REAPER playback — D_VOL removed, Ozone filenames corrected, ReaEQ removed, MIDI quantized
- D_VOL: removed from _build_clip() — not valid at REAPER item level
- Ozone 12: fixed 21 PLUGIN_REGISTRY entries with correct .vst3 filenames
- ReaEQ: removed _calibrate_eq() — built-in plugin format incompatible
- MIDI: quantized all notes to 16th grid (120 ticks at 960 PPQ)

298/298 tests. 0 D_VOL, 0 ReaEQ, all notes on grid, Ozone filenames correct.
2026-05-04 00:55:08 -03:00

550 lines
20 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 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