- 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.
313 lines
12 KiB
Python
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"
|