Files
reaper-control/tests/test_chords.py
renato97 33bb08270d fix: musical content — 808 timing, chord voicings, melody range, pad arpeggiation, Ozone paths
- 808 bass: fixed note positions to beat 1.0 per bar (i-iv-i-V, 1.5 beat duration)
- Chords: 4-note 7th voicings (Am7, F7, C7, G7) instead of 2-note intervals
- Lead: constrained to 8-semitone range, pentatonic scale
- Pad: arpeggiated eighth-notes instead of static 2-note drones
- Ozone 12: fixed .vst3 filename paths in Calibrator
- Delta-encoding: fixed cumulative timing drift in _build_midi_source() with CC events

298/298 tests pass.
2026-05-04 01:30:19 -03:00

324 lines
13 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_seeds_produce_valid_output(self):
"""Different seeds both produce valid 4-note 7th chord voicings.
RNG is a voice-leading tiebreaker — divergence is possible but
not guaranteed when one candidate is mathematically superior.
"""
e1 = ChordEngine("Am", seed=42)
e2 = ChordEngine("Am", seed=99)
r1 = e1.progression(8)
r2 = e2.progression(8)
# Both must produce 8 chords with 4 notes each
assert len(r1) == 8 and all(len(c) == 4 for c in r1)
assert len(r2) == 8 and all(len(c) == 4 for c in r2)
# Both must start with i7 chord
assert r1[0][0] % 12 == 9 # A pitch class
assert r2[0][0] % 12 == 9
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_voice_leading_is_smooth(self):
"""GIVEN any 2 consecutive chords from a progression
WHEN computing voice leading
THEN average voice movement ≤ 4 semitones (soft constraint)."""
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)}"
)
# Soft constraint: average movement ≤ 4 semitones
leaps = [abs(pb - pa) for pa, pb in zip(a, b)]
avg_leap = sum(leaps) / len(leaps)
assert avg_leap <= 4, (
f"Average voice movement from chord {i} to {i+1} is "
f"{avg_leap:.1f} semitones (should be ≤ 4)\n"
f" {a}{b}\n leaps: {leaps}"
)
def test_voice_leading_on_dark_progression(self):
"""Voice leading smoothness holds for dark emotion too."""
engine = ChordEngine("Am", seed=42)
voicings = engine.progression(8, emotion="dark")
for i in range(len(voicings) - 1):
leaps = [abs(pb - pa) for pa, pb in zip(voicings[i], voicings[i + 1])]
avg_leap = sum(leaps) / len(leaps)
assert avg_leap <= 4, f"Dark: avg leap {avg_leap:.1f} > 4"
def test_voice_leading_on_club_progression(self):
"""Voice leading smoothness holds for club emotion."""
engine = ChordEngine("Am", seed=42)
voicings = engine.progression(8, emotion="club")
for i in range(len(voicings) - 1):
leaps = [abs(pb - pa) for pa, pb in zip(voicings[i], voicings[i + 1])]
avg_leap = sum(leaps) / len(leaps)
assert avg_leap <= 4, f"Club: avg leap {avg_leap:.1f} > 4"
def test_voice_leading_on_classic_progression(self):
"""Voice leading smoothness holds for classic emotion."""
engine = ChordEngine("Am", seed=42)
voicings = engine.progression(8, emotion="classic")
for i in range(len(voicings) - 1):
leaps = [abs(pb - pa) for pa, pb in zip(voicings[i], voicings[i + 1])]
avg_leap = sum(leaps) / len(leaps)
assert avg_leap <= 4, f"Classic: avg leap {avg_leap:.1f} > 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_four_note_seventh(self):
"""All chords should be 4-note 7th voicings (m7/7 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) == 4, (
f"{emotion} chord {i}: expected 4 notes (7th), 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"