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

153
scripts/analyze_drumloop.py Normal file
View File

@@ -0,0 +1,153 @@
"""Analyze drumloops from the library and output structured forensic data.
Usage:
python scripts/analyze_drumloop.py [--count N] [--output PATH] [--json PATH]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT))
from src.composer.drum_analyzer import DrumLoopAnalyzer
def load_drumloop_paths(index_path: Path, count: int = 5) -> list[dict]:
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
drumloops = [s for s in data["samples"] if s.get("role") == "drumloop"]
scored = []
for d in drumloops:
path = Path(d["original_path"])
if not path.exists():
continue
dur = d.get("signal", {}).get("duration", 0)
onsets = d.get("perceptual", {}).get("onset_count", 0)
density = onsets / max(dur, 0.01)
tempo = d.get("perceptual", {}).get("tempo", 0)
if 85 <= tempo <= 150 and 6 <= dur <= 35:
scored.append((d, density))
scored.sort(key=lambda x: abs(x[1] - 4.0))
return [s[0] for s in scored[:count]]
def print_analysis(result_dict: dict) -> None:
print(f"\n{'='*70}")
print(f" {Path(result_dict['file_path']).name}")
print(f"{'='*70}")
print(f" BPM: {result_dict['bpm']}")
print(f" Duration: {result_dict['duration']:.2f}s")
print(f" Bars: {result_dict['bar_count']}")
print(f" Key: {result_dict['key']} (conf: {result_dict['key_confidence']:.2f})")
print(f" Beat grid: {len(result_dict['beat_grid']['quarter'])} quarters, "
f"{len(result_dict['beat_grid']['eighth'])} eighths, "
f"{len(result_dict['beat_grid']['sixteenth'])} sixteenths")
summary = result_dict["summary"]
total = summary["kick_count"] + summary["snare_count"] + summary["hihat_count"] + summary["other_count"]
print(f"\n Transients: {total} total")
print(f" Kicks: {summary['kick_count']}")
print(f" Snares: {summary['snare_count']}")
print(f" HiHats: {summary['hihat_count']}")
print(f" Other: {summary['other_count']}")
transients_by_type = {}
for t in result_dict["transients"]:
transients_by_type.setdefault(t["type"], []).append(t)
for ttype in ["kick", "snare", "hihat", "other"]:
ts = transients_by_type.get(ttype, [])
if not ts:
continue
print(f"\n {ttype.upper()} positions (beat positions):")
positions = [f"{t['beat_pos']:.2f}" for t in ts[:20]]
line = " " + " ".join(positions)
if len(ts) > 20:
line += f" ... +{len(ts)-20} more"
print(line)
if result_dict["energy_profile"]:
print(f"\n Energy profile (first 16 beats):")
bars_e = result_dict["energy_profile"][:16]
max_e = max(bars_e) if bars_e else 1
for i, e in enumerate(bars_e):
bar = i // 4
beat = i % 4
filled = int((e / max_e) * 30) if max_e > 0 else 0
print(f" Bar {bar+1} Beat {beat+1}: {'|' * filled} ({e:.4f})")
def main():
parser = argparse.ArgumentParser(description="Analyze drumloops forensically")
parser.add_argument("--count", type=int, default=3, help="Number of drumloops to analyze")
parser.add_argument("--index", type=str, default=None, help="Path to sample_index.json")
parser.add_argument("--file", type=str, default=None, help="Analyze a single file instead")
parser.add_argument("--json", type=str, default=None, help="Save results as JSON")
args = parser.parse_args()
index_path = Path(args.index) if args.index else PROJECT / "data" / "sample_index.json"
results = []
if args.file:
print(f"Analyzing: {args.file}")
analyzer = DrumLoopAnalyzer(args.file)
result = analyzer.analyze()
results.append(result.to_dict())
print_analysis(results[0])
else:
drumloops = load_drumloop_paths(index_path, args.count)
if not drumloops:
print("No suitable drumloops found.")
return
print(f"Selected {len(drumloops)} drumloops for analysis:\n")
for d in drumloops:
print(f" - {d['original_name']} "
f"(tempo={d.get('perceptual',{}).get('tempo','?')}, "
f"dur={d.get('signal',{}).get('duration',0):.1f}s)")
for d in drumloops:
path = d["original_path"]
print(f"\nAnalyzing: {Path(path).name}...")
analyzer = DrumLoopAnalyzer(path)
result = analyzer.analyze()
results.append(result.to_dict())
print_analysis(result.to_dict())
if args.json:
out_path = Path(args.json)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\nResults saved to: {out_path}")
print(f"\n{'='*70}")
print("DRUMLOOP-FIRST GENERATION APPROACH")
print(f"{'='*70}")
print("""
1. SELECT drumloop -> extract BPM + beat grid + transient map
2. ALIGN project -> set REAPER tempo to drumloop BPM
3. GENERATE bass -> tresillo pattern in kick-free zones
- Reggaeton tresillo: notes at 0.0, 0.75, 1.5, 2.0, 2.75, 3.5
- Place bass between kick transients (margin +/-0.15 beats)
4. GENERATE chords -> change on downbeats (beat 1 of each bar)
- Sustain through bar, use i-VI-III-VII progression
- Match key from drumloop analysis
5. GENERATE melody -> place on transient-free zones
- Emphasize chord tones on strong beats
- Syncopation matches dembow feel
6. GENERATE vocals -> chops in gaps between drum transients
7. SELECT samples -> match drumloop key for compatible tonal samples
""")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff