feat: drumloop-first generation with forensic analysis

- Add DrumLoopAnalyzer: extracts BPM, transients, key, beat grid from drumloops
- Rewrite compose.py: drumloop drives everything (BPM, key, rhythm)
- Bass tresillo pattern placed in kick-free zones
- Chords change on downbeats matching drumloop key
- Melody avoids transients, emphasizes chord tones
- Vocal chops between transients, clap on dembow (beats 2, 3.5)
- Remove COLOR token (not recognized by REAPER)
- 90 tests passing, generates drumloop_song.rpp with 10 tracks, 20 plugins
This commit is contained in:
renato97
2026-05-03 19:41:22 -03:00
parent 672607c356
commit a2713abd40
10 changed files with 6234 additions and 912 deletions

159
tests/test_drum_analyzer.py Normal file
View File

@@ -0,0 +1,159 @@
"""Tests for DrumLoopAnalyzer."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.composer.drum_analyzer import BeatGrid, DrumLoopAnalyzer, DrumLoopAnalysis, Transient
@pytest.fixture
def synthetic_kick(tmp_path):
sr = 44100
dur = 2.0
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
y = np.zeros_like(t)
for pos in [0.0, 0.5, 1.0, 1.5]:
idx = int(pos * sr)
freq_sweep = np.exp(-np.linspace(0, 8, 800)) * np.sin(
2 * np.pi * np.linspace(150, 40, 800) * np.linspace(0, 0.02, 800)
)
end = min(idx + len(freq_sweep), len(y))
y[idx:end] += freq_sweep[: end - idx]
path = tmp_path / "synth_kick.wav"
sf.write(str(path), y, sr)
return str(path)
@pytest.fixture
def synthetic_drumloop(tmp_path):
sr = 44100
bpm = 120
dur = 4.0
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
y = np.zeros_like(t)
beat = 60.0 / bpm
for bar in range(2):
off = bar * 4 * beat
for p in [0.0, 2.0 * beat, 3.5 * beat]:
idx = int((off + p) * sr)
n = 600
kick = np.exp(-np.linspace(0, 10, n)) * np.sin(
2 * np.pi * np.linspace(160, 35, n) * np.linspace(0, 0.03, n)
)
end = min(idx + n, len(y))
y[idx:end] += kick[: end - idx] * 0.8
for p in [1.0 * beat, 2.5 * beat]:
idx = int((off + p) * sr)
n = 1200
noise = np.random.RandomState(42).randn(n) * np.exp(-np.linspace(0, 6, n))
snare = np.sin(2 * np.pi * 200 * np.linspace(0, 0.05, n)) * np.exp(-np.linspace(0, 5, n))
end = min(idx + n, len(y))
y[idx:end] += (noise + snare)[: end - idx] * 0.5
for i in range(8):
p = i * beat / 2
idx = int((off + p) * sr)
n = 200
hh = np.random.RandomState(i).randn(n) * np.exp(-np.linspace(0, 20, n))
end = min(idx + n, len(y))
y[idx:end] += hh[: end - idx] * 0.15
y = y / (np.max(np.abs(y)) + 1e-10) * 0.9
path = tmp_path / "synth_drumloop.wav"
sf.write(str(path), y, sr)
return str(path)
class TestDrumLoopAnalyzer:
def test_analyze_returns_result(self, synthetic_drumloop):
analyzer = DrumLoopAnalyzer(synthetic_drumloop)
result = analyzer.analyze()
assert isinstance(result, DrumLoopAnalysis)
assert result.bpm > 0
assert result.duration > 0
assert len(result.beats) > 0
assert len(result.transients) > 0
assert isinstance(result.beat_grid, BeatGrid)
assert len(result.beat_grid.quarter) > 0
def test_bpm_reasonable(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
assert 60 <= result.bpm <= 200, f"BPM {result.bpm} out of range"
def test_transient_classification(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
types = {t.type for t in result.transients}
valid = {"kick", "snare", "hihat", "other"}
assert types <= valid, f"Unexpected types: {types - valid}"
def test_beat_grid_populated(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
grid = result.beat_grid
assert len(grid.quarter) > 0
assert len(grid.eighth) >= len(grid.quarter)
assert len(grid.sixteenth) >= len(grid.eighth)
def test_key_detection(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
assert result.key is not None
assert result.key_confidence >= 0
def test_energy_profile(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
assert len(result.energy_profile) > 0
assert all(e >= 0 for e in result.energy_profile)
def test_to_dict_roundtrip(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
d = result.to_dict()
assert d["bpm"] == round(result.bpm, 2)
assert d["duration"] == round(result.duration, 4)
assert len(d["transients"]) == len(result.transients)
assert "summary" in d
json.dumps(d)
def test_kick_free_zones(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
zones = result.kick_free_zones(margin_beats=0.2)
assert isinstance(zones, list)
for start, end in zones:
assert end > start
def test_transient_positions(self, synthetic_drumloop):
result = DrumLoopAnalyzer(synthetic_drumloop).analyze()
all_pos = result.transient_positions()
kick_pos = result.transient_positions("kick")
assert len(all_pos) >= len(kick_pos)
def test_real_drumloop_if_exists(self):
path = Path(
r"C:\Users\Administrator\Documents\fl_control\libreria\samples\drumloop"
r"\drumloop_E3_120_boomy_accb48.wav"
)
if not path.exists():
pytest.skip("Real drumloop not available")
result = DrumLoopAnalyzer(str(path)).analyze()
assert 100 <= result.bpm <= 140, f"BPM {result.bpm} unexpected"
assert result.bar_count > 0
kicks = result.transients_of_type("kick")
snares = result.transients_of_type("snare")
assert len(kicks) > 0, "No kicks detected"
assert len(snares) >= 0
class TestTransient:
def test_transient_creation(self):
t = Transient(time=0.5, type="kick", energy=0.8, spectral_centroid=120.0)
assert t.time == 0.5
assert t.type == "kick"