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.
This commit is contained in:
renato97
2026-05-03 23:54:29 -03:00
parent 48bc271afc
commit 014e636889
51 changed files with 11394 additions and 113 deletions

448
tests/test_melody_engine.py Normal file
View File

@@ -0,0 +1,448 @@
"""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