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:
153
scripts/analyze_drumloop.py
Normal file
153
scripts/analyze_drumloop.py
Normal 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
Reference in New Issue
Block a user