- 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
154 lines
5.8 KiB
Python
154 lines
5.8 KiB
Python
"""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()
|