Files
reaper-control/tests/test_chords.py
renato97 014e636889 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.
2026-05-03 23:54:29 -03:00

313 lines
12 KiB
Python

"""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"