- 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.
449 lines
17 KiB
Python
449 lines
17 KiB
Python
"""Unit tests for src/composer/melody_engine.py — hook-based melody generation."""
|
|
|
|
import pytest
|
|
from src.composer.melody_engine import (
|
|
build_motif,
|
|
apply_variation,
|
|
build_call_response,
|
|
_resolve_chord_tones,
|
|
_resolve_tension_notes,
|
|
_resolve_tonic,
|
|
_get_pentatonic,
|
|
_get_diatonic,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.1 — Determinism
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMotifDeterministic:
|
|
"""Same seed → identical output."""
|
|
|
|
def test_motif_deterministic_hook(self):
|
|
a = build_motif("A", True, "hook", 4, 42)
|
|
b = build_motif("A", True, "hook", 4, 42)
|
|
assert a == b, "Same seed must produce identical motfs"
|
|
assert len(a) > 0, "Hook should produce notes"
|
|
|
|
def test_motif_deterministic_stabs(self):
|
|
a = build_motif("A", True, "stabs", 2, 1)
|
|
b = build_motif("A", True, "stabs", 2, 1)
|
|
assert a == b
|
|
|
|
def test_motif_deterministic_smooth(self):
|
|
a = build_motif("A", True, "smooth", 4, 7)
|
|
b = build_motif("A", True, "smooth", 4, 7)
|
|
assert a == b
|
|
|
|
def test_motif_deterministic_different_style(self):
|
|
hook = build_motif("A", True, "hook", 4, 42)
|
|
stabs = build_motif("A", True, "stabs", 4, 42)
|
|
assert hook != stabs, "Different styles should produce different output"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.2 — Different seeds
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMotifDifferentSeeds:
|
|
"""Different seeds → different output."""
|
|
|
|
def test_motif_different_seeds_hook(self):
|
|
a = build_motif("A", True, "hook", 4, 42)
|
|
b = build_motif("A", True, "hook", 4, 99)
|
|
assert a != b, "Different seeds must produce different output"
|
|
|
|
def test_motif_different_seeds_stabs(self):
|
|
a = build_motif("A", True, "stabs", 4, 1)
|
|
b = build_motif("A", True, "stabs", 4, 2)
|
|
assert a != b
|
|
|
|
def test_motif_different_seeds_smooth(self):
|
|
a = build_motif("A", True, "smooth", 4, 1)
|
|
b = build_motif("A", True, "smooth", 4, 2)
|
|
assert a != b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.3 — Invalid style
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInvalidStyle:
|
|
"""Invalid style raises ValueError."""
|
|
|
|
def test_invalid_style_raises_value_error(self):
|
|
with pytest.raises(ValueError, match="Invalid style"):
|
|
build_motif("A", True, "invalid", 4, 42)
|
|
|
|
def test_value_error_mentions_valid_styles(self):
|
|
with pytest.raises(ValueError) as exc:
|
|
build_motif("A", True, "xyz", 4, 42)
|
|
msg = str(exc.value)
|
|
assert "hook" in msg
|
|
assert "stabs" in msg
|
|
assert "smooth" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.4 — Chord tones on strong beats (hook)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestHookChordTones:
|
|
"""Hook style: ≥70% of quarter-position notes are chord tones."""
|
|
|
|
@staticmethod
|
|
def _quarter_position_notes(notes):
|
|
"""Return notes whose start time is on a quarter-beat boundary."""
|
|
return [n for n in notes if abs(n.start % 1.0) < 0.001]
|
|
|
|
@staticmethod
|
|
def _is_chord_tone(pitch, key_root, key_minor, bar, bar_offset=0):
|
|
"""Check if pitch belongs to the active chord at the given bar."""
|
|
chord_tones = _resolve_chord_tones(key_root, key_minor, bar)
|
|
return any(abs(pitch - ct) % 12 == 0 for ct in chord_tones)
|
|
|
|
def test_hook_chord_tones_on_strong_beats(self):
|
|
"""≥70% of notes on quarter positions are chord tones."""
|
|
notes = build_motif("A", True, "hook", 8, 42)
|
|
quarter_notes = self._quarter_position_notes(notes)
|
|
assert len(quarter_notes) > 0, "Hook must have notes on quarter positions"
|
|
|
|
chord_count = 0
|
|
for note in quarter_notes:
|
|
bar = int(note.start // 4.0)
|
|
if self._is_chord_tone(note.pitch, "A", True, bar):
|
|
chord_count += 1
|
|
|
|
ratio = chord_count / len(quarter_notes)
|
|
assert ratio >= 0.70, (
|
|
f"Chord tone ratio on strong beats: {ratio:.1%}, need ≥ 70%\n"
|
|
f"Pitches: {[n.pitch for n in quarter_notes]}"
|
|
)
|
|
|
|
def test_hook_produces_notes(self):
|
|
"""Hook should produce a reasonable number of notes."""
|
|
notes = build_motif("A", True, "hook", 4, 42)
|
|
assert 16 <= len(notes) <= 24, (
|
|
f"Expected 16-24 notes for 4-bar hook, got {len(notes)}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.5 — Stabs grid alignment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestStabsGridAlignment:
|
|
"""Stabs: all notes on dembow positions [1.0, 2.5, 3.0, 3.5] per bar."""
|
|
|
|
DEMBOW = {1.0, 2.5, 3.0, 3.5}
|
|
|
|
def test_stabs_grid_alignment(self):
|
|
notes = build_motif("A", True, "stabs", 4, 1)
|
|
assert len(notes) > 0, "Stabs must produce notes"
|
|
|
|
for note in notes:
|
|
bar_start = int(note.start // 4.0) * 4.0
|
|
pos_in_bar = note.start - bar_start
|
|
# Allow tiny floating-point tolerance
|
|
assert any(
|
|
abs(pos_in_bar - dp) < 0.001 for dp in self.DEMBOW
|
|
), f"Note at {note.start} (pos_in_bar={pos_in_bar}) not on dembow grid"
|
|
|
|
def test_stabs_duration_16th(self):
|
|
"""All stabs should be 16th notes (≤ 0.25 beats)."""
|
|
notes = build_motif("A", True, "stabs", 4, 1)
|
|
for note in notes:
|
|
assert note.duration <= 0.25, (
|
|
f"Stab duration {note.duration} > 16th note"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.6 — Smooth stepwise motion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSmoothStepwise:
|
|
"""Smooth style: consecutive notes differ by ≤ 2 semitones."""
|
|
|
|
def test_smooth_stepwise_motion(self):
|
|
notes = build_motif("A", True, "smooth", 4, 7)
|
|
assert len(notes) >= 8, f"Expected at least 8 notes, got {len(notes)}"
|
|
|
|
sorted_notes = sorted(notes, key=lambda n: n.start)
|
|
for i in range(len(sorted_notes) - 1):
|
|
diff = abs(sorted_notes[i + 1].pitch - sorted_notes[i].pitch)
|
|
assert diff <= 2, (
|
|
f"Step from pitch {sorted_notes[i].pitch} to {sorted_notes[i + 1].pitch} "
|
|
f"at beat {sorted_notes[i + 1].start}: diff={diff} > 2"
|
|
)
|
|
|
|
def test_smooth_eighth_note_density(self):
|
|
"""Smooth style should produce notes at roughly eighth-note spacing."""
|
|
notes = build_motif("A", True, "smooth", 4, 7)
|
|
sorted_notes = sorted(notes, key=lambda n: n.start)
|
|
|
|
# Each note should be ~0.5 beats apart (eighth note)
|
|
# Check that most gaps are close to 0.5
|
|
gaps = []
|
|
for i in range(len(sorted_notes) - 1):
|
|
gap = sorted_notes[i + 1].start - sorted_notes[i].start
|
|
gaps.append(gap)
|
|
|
|
avg_gap = sum(gaps) / len(gaps) if gaps else 0
|
|
assert 0.4 < avg_gap < 0.6, (
|
|
f"Expected eighth-note spacing (~0.5), got avg gap {avg_gap:.3f}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.7 — Variation preserves structure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestVariation:
|
|
"""apply_variation() preserves note count, durations, and IOIs."""
|
|
|
|
def test_variation_preserves_note_count(self):
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
variant = apply_variation(motif, shift_beats=0.25)
|
|
assert len(variant) == len(motif)
|
|
|
|
def test_variation_preserves_durations(self):
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
variant = apply_variation(motif, shift_beats=0.25, transpose_semitones=3)
|
|
for orig, var in zip(motif, variant):
|
|
assert var.duration == orig.duration, (
|
|
f"Duration mismatch: {var.duration} != {orig.duration}"
|
|
)
|
|
|
|
def test_variation_preserves_iois(self):
|
|
"""Inter-onset intervals are preserved after shift."""
|
|
motif = sorted(build_motif("A", True, "hook", 4, 42), key=lambda n: n.start)
|
|
variant = sorted(
|
|
apply_variation(motif, shift_beats=0.25),
|
|
key=lambda n: n.start,
|
|
)
|
|
|
|
for i in range(len(motif) - 1):
|
|
orig_ioi = motif[i + 1].start - motif[i].start
|
|
var_ioi = variant[i + 1].start - variant[i].start
|
|
assert abs(orig_ioi - var_ioi) < 0.001, (
|
|
f"IOI mismatch at index {i}: {var_ioi:.4f} != {orig_ioi:.4f}"
|
|
)
|
|
|
|
def test_variation_shifts_start_times(self):
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
variant = apply_variation(motif, shift_beats=0.25)
|
|
for orig, var in zip(
|
|
sorted(motif, key=lambda n: n.start),
|
|
sorted(variant, key=lambda n: n.start),
|
|
):
|
|
assert abs(var.start - orig.start - 0.25) < 0.001
|
|
|
|
def test_variation_transposes_pitches(self):
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
variant = apply_variation(motif, transpose_semitones=3)
|
|
for orig, var in zip(motif, variant):
|
|
assert var.pitch == orig.pitch + 3
|
|
|
|
def test_variation_empty_motif(self):
|
|
result = apply_variation([], shift_beats=1.0)
|
|
assert result == []
|
|
|
|
def test_variation_defaults(self):
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
variant = apply_variation(motif)
|
|
assert len(variant) == len(motif)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.8 — Call ends on tension, response on tonic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCallResponseResolution:
|
|
"""build_call_response(): call → V/VII, response → tonic."""
|
|
|
|
def test_call_ends_on_tension_response_ends_on_tonic(self):
|
|
"""Call (first half) last note = V or VII; response last note = tonic."""
|
|
# Am: tonic = A(69), V = E(76), VII = G(79)
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
result = build_call_response(motif, bars=8, key_root="A", key_minor=True, seed=42)
|
|
assert len(result) > 0
|
|
|
|
# Sort by start time
|
|
sorted_notes = sorted(result, key=lambda n: n.start)
|
|
|
|
# Find last note of first 4 bars (call)
|
|
call_cutoff = 4.0 * 4 # 4 bars * 4 beats
|
|
call_notes = [n for n in sorted_notes if n.start < call_cutoff]
|
|
assert len(call_notes) > 0, "No notes in call half"
|
|
last_call_pitch = call_notes[-1].pitch % 12
|
|
|
|
# V of Am is E (pitch%12=4), VII is G (pitch%12=7)
|
|
assert last_call_pitch in (4, 7), (
|
|
f"Last call note pitch class {last_call_pitch} must be V(4=E) or VII(7=G)"
|
|
)
|
|
|
|
# Last note overall (response) must be tonic A (pitch%12=9)
|
|
last_note = sorted_notes[-1].pitch % 12
|
|
assert last_note == 9, (
|
|
f"Last note pitch class {last_note} must be tonic A(9)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3.9 — Call-response fills bars with motif repetition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCallResponseFillsBars:
|
|
"""build_call_response() fills section with motif repetition."""
|
|
|
|
def test_call_response_fills_bars(self):
|
|
"""A 2-bar motif repeated to fill 8 bars."""
|
|
motif = build_motif("A", True, "hook", 2, 42)
|
|
result = build_call_response(motif, bars=8, key_root="A", key_minor=True, seed=42)
|
|
|
|
# Total span should be ~8 bars (32 beats)
|
|
max_end = max(n.start + n.duration for n in result) if result else 0
|
|
min_start = min(n.start for n in result) if result else 0
|
|
span = max_end - min_start
|
|
assert span >= 28, f"Notes should span ~32 beats (8 bars), got {span}"
|
|
|
|
# Motif content should repeat at least 2 times within 8 bars
|
|
assert len(result) >= len(motif) * 2, (
|
|
f"Motif repeats: expected ≥{len(motif)*2} notes, got {len(result)}"
|
|
)
|
|
|
|
def test_call_response_empty_motif(self):
|
|
result = build_call_response([], bars=8, key_root="A", key_minor=True)
|
|
assert result == []
|
|
|
|
def test_call_response_length_matches_bars(self):
|
|
"""Result should not exceed `bars` worth of material."""
|
|
motif = build_motif("A", True, "hook", 4, 42)
|
|
for test_bars in (2, 4, 8):
|
|
result = build_call_response(motif, bars=test_bars,
|
|
key_root="A", key_minor=True, seed=42)
|
|
max_end = max((n.start + n.duration for n in result), default=0)
|
|
assert max_end <= test_bars * 4.0 + 0.001, (
|
|
f"For {test_bars} bars, max_end={max_end} exceeds {test_bars * 4.0}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestResolveChordTones:
|
|
"""_resolve_chord_tones returns correct pitches."""
|
|
|
|
def test_chord_tones_am_bar0(self):
|
|
"""Bar 0 of Am should return Am chord tones (A, C, E)."""
|
|
tones = _resolve_chord_tones("A", True, 0, 4)
|
|
# Check for pitch classes 9(A), 0(C), 4(E)
|
|
pitch_classes = {p % 12 for p in tones}
|
|
assert 9 in pitch_classes, "A missing"
|
|
assert 0 in pitch_classes, "C missing"
|
|
assert 4 in pitch_classes, "E missing"
|
|
|
|
def test_chord_tones_am_bar2(self):
|
|
"""Bar 2 of Am should return F major (F, A, C) — the VI chord."""
|
|
tones = _resolve_chord_tones("A", True, 2, 4)
|
|
pitch_classes = {p % 12 for p in tones}
|
|
assert 5 in pitch_classes, "F missing" # F = 5
|
|
assert 9 in pitch_classes, "A missing" # A = 9
|
|
assert 0 in pitch_classes, "C missing" # C = 0
|
|
|
|
def test_chord_tones_wraps(self):
|
|
"""Bar 8 wraps back to chord i."""
|
|
tones0 = _resolve_chord_tones("A", True, 0, 4)
|
|
tones8 = _resolve_chord_tones("A", True, 8, 4)
|
|
p0 = {p % 12 for p in tones0}
|
|
p8 = {p % 12 for p in tones8}
|
|
assert p0 == p8, "Bar 0 and bar 8 should have same chord tones (wrapped)"
|
|
|
|
|
|
class TestResolveTensionNotes:
|
|
"""_resolve_tension_notes returns correct V and VII."""
|
|
|
|
def test_tension_notes_am(self):
|
|
v_pitch, vii_pitch = _resolve_tension_notes("A", True, 4)
|
|
# V of A = E (MIDI 69 + 7 = 76)
|
|
assert v_pitch == 76, f"V of Am should be E (76), got {v_pitch}"
|
|
# VII of Am minor = G (MIDI 69 + 10 = 79)
|
|
assert vii_pitch == 79, f"VII of Am should be G (79), got {vii_pitch}"
|
|
|
|
def test_tension_notes_cm(self):
|
|
v_pitch, vii_pitch = _resolve_tension_notes("C", True, 4)
|
|
# C4=60, V=G4=60+7=67
|
|
assert v_pitch == 67, f"V of Cm should be G4 (67), got {v_pitch}"
|
|
# VII of C minor = Bb4 = 60+10=70
|
|
assert vii_pitch == 70, f"VII of Cm should be Bb4 (70), got {vii_pitch}"
|
|
|
|
|
|
class TestResolveTonic:
|
|
"""_resolve_tonic returns correct pitch."""
|
|
|
|
def test_tonic_am(self):
|
|
assert _resolve_tonic("A", 4) == 69 # A4
|
|
|
|
def test_tonic_dm(self):
|
|
assert _resolve_tonic("D", 4) == 62 # D4
|
|
|
|
|
|
class TestScaleHelpers:
|
|
"""_get_pentatonic and _get_diatonic."""
|
|
|
|
def test_pentatonic_am(self):
|
|
notes = _get_pentatonic("A", True, 4)
|
|
pitch_classes = {n % 12 for n in notes}
|
|
assert pitch_classes == {9, 0, 2, 4, 7}, (
|
|
f"Am pentatonic: A C D E G, got {pitch_classes}"
|
|
)
|
|
|
|
def test_pentatonic_c_major(self):
|
|
notes = _get_pentatonic("C", False, 4)
|
|
pitch_classes = {n % 12 for n in notes}
|
|
assert pitch_classes == {0, 2, 4, 7, 9}, (
|
|
f"C major pentatonic: C D E G A, got {pitch_classes}"
|
|
)
|
|
|
|
def test_diatonic_am(self):
|
|
notes = _get_diatonic("A", True, 4)
|
|
pitch_classes = {n % 12 for n in notes}
|
|
assert pitch_classes == {9, 11, 0, 2, 4, 5, 7}, (
|
|
f"Am natural minor: A B C D E F G, got {pitch_classes}"
|
|
)
|
|
|
|
def test_diatonic_c_major(self):
|
|
notes = _get_diatonic("C", False, 4)
|
|
pitch_classes = {n % 12 for n in notes}
|
|
assert pitch_classes == {0, 2, 4, 5, 7, 9, 11}, (
|
|
f"C major: C D E F G A B, got {pitch_classes}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-style tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCrossStyle:
|
|
"""Tests covering all three styles."""
|
|
|
|
def test_all_styles_return_midi_notes(self):
|
|
for style in ("hook", "stabs", "smooth"):
|
|
notes = build_motif("A", True, style, 4, 0)
|
|
assert isinstance(notes, list)
|
|
assert len(notes) > 0, f"Style '{style}' returned empty list"
|
|
assert all(hasattr(n, "pitch") for n in notes)
|
|
assert all(hasattr(n, "start") for n in notes)
|
|
|
|
def test_different_key_produces_different_output(self):
|
|
am = build_motif("A", True, "hook", 4, 42)
|
|
dm = build_motif("D", True, "hook", 4, 42)
|
|
assert am != dm, "Different keys should produce different motifs"
|
|
|
|
def test_major_key_produces_notes(self):
|
|
notes = build_motif("C", False, "hook", 4, 42)
|
|
assert len(notes) > 0
|