feat: drumloop-first v2 — scored selection, WAV preference, no vocals

- Score drumloops by key_confidence + onset_density + duration + balance + format
- Prefer WAV over MP3 (lossless > lossy)
- 6 tracks only: Drumloop, Bass, Chords, Lead, Clap, Pad
- Clap ONLY in chorus+verse (dembow on beats 2, 3.5)
- Bass tresillo filtered by kick-free zones
- Chords i-VI-III-VII on downbeats
- Lead pentatonic, avoids transients, chord tones on strong beats
- Master chain: Pro-Q 3 → Pro-C 2 → Pro-L 2
- 90 tests passing, 18 plugins, clean RPP output
This commit is contained in:
renato97
2026-05-03 20:07:00 -03:00
parent a2713abd40
commit 7729d5f12f
4 changed files with 2439 additions and 432 deletions

2019
output/drumloop_v2.rpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,153 +0,0 @@
"""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()

View File

@@ -1,12 +1,14 @@
#!/usr/bin/env python
"""Drumloop-first REAPER .rpp project generator for reggaeton.
"""Drumloop-first REAPER .rpp project generator for reggaeton instrumental.
The drumloop drives EVERYTHING: BPM, key, rhythm all come from analysis.
Bass, chords, melody, and vocals are built to sync with the drumloop's rhythm.
Bass, chords, lead, and pad are built to sync with the drumloop's rhythm.
NO vocals — this is an instrumental-only generator.
Usage:
python scripts/compose.py --output output/song.rpp
python scripts/compose.py --output output/drumloop_v2.rpp
python scripts/compose.py --bpm 95 --key Am --output output/song.rpp
python scripts/compose.py --bpm 95 --key Am --seed 42 --output output/song.rpp
"""
from __future__ import annotations
@@ -39,60 +41,147 @@ ROLE_COLORS = {
"clap": 4,
"bass": 5,
"chords": 9,
"melody": 11,
"lead": 11,
"pad": 13,
"vocal": 15,
}
# Section structure: (name, bars, energy, has_clap)
# Clap ONLY on chorus and verse sections
SECTIONS = [
("intro", 4, 0.4),
("verse", 8, 0.6),
("build", 4, 0.7),
("chorus", 8, 1.0),
("break", 4, 0.5),
("chorus", 8, 1.0),
("outro", 4, 0.3),
("intro", 4, 0.4, False),
("verse", 8, 0.6, True),
("build", 4, 0.7, False),
("chorus", 8, 1.0, True),
("break", 4, 0.5, False),
("chorus", 8, 1.0, True),
("outro", 4, 0.3, False),
]
# Tresillo rhythm positions in beats (within a bar)
TRESILLO_POSITIONS = [0.0, 0.75, 1.5, 2.0, 2.75, 3.5]
# Clap positions in beats (within a bar)
CLAP_POSITIONS = [1.0, 3.5]
# i-VI-III-VII chord progression in semitones from root (minor key)
CHORD_PROGRESSION = [
(0, "minor"),
(8, "major"),
(3, "major"),
(10, "major"),
(0, "minor"), # i
(8, "major"), # VI
(3, "major"), # III
(10, "major"), # VII
]
# FX chains per track role (before return sends)
FX_CHAINS = {
"drumloop": ["Decapitator", "Radiator"],
"bass": ["Decapitator", "Gullfoss_Master"],
"chords": ["PhaseMistress", "EchoBoy"],
"melody": ["Tremolator"],
"vocal": ["VC_76", "Radiator", "EchoBoy"],
"pad": ["ValhallaDelay"],
"bass": ["Serum_2", "Decapitator", "Gullfoss_Master"],
"chords": ["Omnisphere", "PhaseMistress", "EchoBoy"],
"lead": ["Serum_2", "Tremolator"],
"clap": ["Decapitator"],
"pad": ["Omnisphere", "ValhallaDelay"],
}
# Send levels (reverb, delay) per track role
SEND_LEVELS = {
"bass": (0.05, 0.02),
"bass": (0.05, 0.02),
"chords": (0.15, 0.08),
"melody": (0.10, 0.05),
"vocal": (0.20, 0.10),
"pad": (0.25, 0.15),
"lead": (0.10, 0.05),
"clap": (0.05, 0.02),
"pad": (0.25, 0.15),
}
# Track volume levels
VOLUME_LEVELS = {
"bass": 0.82,
"drumloop": 0.85,
"chords": 0.70,
"melody": 0.75,
"vocal": 0.80,
"pad": 0.65,
"clap": 0.80,
"bass": 0.82,
"chords": 0.70,
"lead": 0.75,
"clap": 0.80,
"pad": 0.65,
}
# Backward compat stubs for test imports
EFFECT_ALIASES: dict[str, str] = {}
# Master volume
MASTER_VOLUME = 0.85
# ---------------------------------------------------------------------------
# Phase 1: Infrastructure
# ---------------------------------------------------------------------------
def score_drumloop(sample: dict, analysis) -> float:
"""Score a drumloop candidate for selection quality.
Formula: key_confidence*0.4 + onset_density_normalized*0.3 + duration_score*0.2 + balance_score*0.1
Args:
sample: sample dict from index (used for duration)
analysis: DrumLoopAnalysis result
Returns:
Composite score 0.01.0 (higher = better)
"""
# key_confidence: already 0-1 from analysis
kc = analysis.key_confidence
# onset_density_normalized: normalize against typical max (15.0)
transients = analysis.transients
duration = analysis.duration
onset_density = len(transients) / duration if duration > 0 else 0.0
onset_density_normalized = min(1.0, onset_density / 15.0)
# duration_score: prefer >= 8 second loops for clean looping
dur = sample.get("signal", {}).get("duration", 0.0)
duration_score = 1.0 if dur >= 8.0 else dur / 8.0
# balance_score: penalize if kick/snare ratio is lopsided
kick_count = len(analysis.transients_of_type("kick"))
snare_count = len(analysis.transients_of_type("snare"))
total = len(transients) if transients else 1
kick_ratio = kick_count / total
snare_ratio = snare_count / total
balance_score = 2.0 * min(kick_ratio, snare_ratio)
balance_score = min(1.0, balance_score)
# format_score: prefer WAV over MP3 (lossless > lossy)
ext = sample.get("original_path", "").rsplit(".", 1)[-1].lower()
format_score = 1.0 if ext == "wav" else 0.85
return kc * 0.3 + onset_density_normalized * 0.25 + duration_score * 0.15 + balance_score * 0.1 + format_score * 0.2
def build_section_structure():
"""Build section list and compute cumulative bar offsets.
Returns:
sections: list of SectionDef
offsets: list of bar offsets (cumulative, in bars)
"""
sections = [SectionDef(name=n, bars=b, energy=e) for n, b, e, _ in SECTIONS]
offsets = []
off = 0.0
for sec in sections:
offsets.append(off)
off += sec.bars
return sections, offsets
def root_to_midi(root: str, octave: int) -> int:
"""Backward compat: convert note name (e.g. 'C', 'A') to MIDI number."""
return NOTE_TO_MIDI[root] + (octave + 1) * 12
def key_to_midi_root(key_str: str, octave: int = 2) -> int:
"""Convert key string (e.g. "Am") to MIDI root note number.
Args:
key_str: Key like "Am", "Dm", "Gm", "C", "F#m"
octave: MIDI octave (2 = bass, 3 = chords/pad)
Returns:
MIDI note number (e.g. 45 for A2, 57 for A3)
"""
root = key_str.rstrip("m")
return NOTE_TO_MIDI[root] + (octave + 1) * 12
# ---------------------------------------------------------------------------
@@ -100,25 +189,24 @@ EFFECT_ALIASES: dict[str, str] = {}
# ---------------------------------------------------------------------------
def parse_key(key_str: str) -> tuple[str, bool]:
"""Parse key string into root and minor flag."""
if key_str.endswith("m"):
return key_str[:-1], True
return key_str, False
def root_to_midi(root: str, octave: int) -> int:
return NOTE_TO_MIDI[root] + (octave + 1) * 12
def get_pentatonic(root: str, is_minor: bool, octave: int) -> list[int]:
root_midi = root_to_midi(root, octave)
"""Get pentatonic scale pitches for root in given octave."""
root_midi = key_to_midi_root(root, octave)
if is_minor:
intervals = [0, 3, 5, 7, 10]
intervals = [0, 3, 5, 7, 10] # minor pentatonic
else:
intervals = [0, 2, 4, 7, 9]
intervals = [0, 2, 4, 7, 9] # major pentatonic
return [root_midi + i for i in intervals]
def build_chord(root_midi: int, quality: str) -> list[int]:
"""Build a triad chord from root MIDI note and quality."""
if quality == "minor":
return [root_midi, root_midi + 3, root_midi + 7]
return [root_midi, root_midi + 4, root_midi + 7]
@@ -129,6 +217,7 @@ def build_chord(root_midi: int, quality: str) -> list[int]:
# ---------------------------------------------------------------------------
def make_plugin(registry_key: str, index: int) -> PluginDef:
"""Create a PluginDef from the PLUGIN_REGISTRY."""
if registry_key in PLUGIN_REGISTRY:
display, path, uid = PLUGIN_REGISTRY[registry_key]
preset = PLUGIN_PRESETS.get(registry_key)
@@ -137,30 +226,46 @@ def make_plugin(registry_key: str, index: int) -> PluginDef:
# ---------------------------------------------------------------------------
# Track builders
# Phase 2: Track Generation
# ---------------------------------------------------------------------------
def build_drumloop_track(drumloop_path: str, total_beats: float) -> TrackDef:
clips = [ClipDef(
position=0.0, length=total_beats, name="Drumloop Full",
audio_path=drumloop_path, loop=True,
)]
"""Build the drumloop track — single audio clip spanning entire song, looping."""
clips = [
ClipDef(
position=0.0,
length=total_beats,
name="Drumloop Full",
audio_path=drumloop_path,
loop=True,
)
]
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("drumloop", []))]
return TrackDef(
name="Drumloop", volume=VOLUME_LEVELS["drumloop"], pan=0.0,
color=ROLE_COLORS["drumloop"], clips=clips, plugins=plugins,
name="Drumloop",
volume=VOLUME_LEVELS["drumloop"],
pan=0.0,
color=ROLE_COLORS["drumloop"],
clips=clips,
plugins=plugins,
)
def build_bass_track(
analysis, sections, offsets, key_root, key_minor,
analysis,
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
) -> TrackDef:
root_midi = root_to_midi(key_root, 2)
"""Build the bass track — MIDI tresillo, filtered by kick_free_zones."""
root_midi = key_to_midi_root(key_root, 2)
beat_dur = 60.0 / analysis.bpm
kfz = analysis.kick_free_zones(margin_beats=0.25)
def in_kfz(beat: float) -> bool:
s = beat * beat_dur
def in_kfz(abs_beat: float) -> bool:
"""Check if absolute beat position is in a kick-free zone."""
s = abs_beat * beat_dur
return any(zs <= s <= ze for zs, ze in kfz)
clips = []
@@ -171,28 +276,41 @@ def build_bass_track(
for pos in TRESILLO_POSITIONS:
abs_beat = sec_off * 4.0 + bar * 4.0 + pos
if in_kfz(abs_beat):
# Note: position within clip is relative to clip start (bar * 4.0)
notes.append(MidiNote(
pitch=root_midi, start=bar * 4.0 + pos,
duration=0.5, velocity=int(100 * vm),
pitch=root_midi,
start=bar * 4.0 + pos,
duration=0.5,
velocity=int(100 * vm),
))
if notes:
clips.append(ClipDef(
position=sec_off * 4.0, length=section.bars * 4.0,
name=f"{section.name.capitalize()} Bass", midi_notes=notes,
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Bass",
midi_notes=notes,
))
plugins = [make_plugin("Serum_2", 0)]
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("bass", []))]
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("bass", []))]
return TrackDef(
name="Bass", volume=VOLUME_LEVELS["bass"], pan=0.0,
color=ROLE_COLORS["bass"], clips=clips, plugins=plugins,
name="Bass",
volume=VOLUME_LEVELS["bass"],
pan=0.0,
color=ROLE_COLORS["bass"],
clips=clips,
plugins=plugins,
)
def build_chords_track(
analysis, sections, offsets, key_root, key_minor,
analysis,
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
) -> TrackDef:
root_midi = root_to_midi(key_root, 3)
"""Build the chords track — i-VI-III-VII on downbeats, one clip per section."""
root_midi = key_to_midi_root(key_root, 3)
clips = []
for section, sec_off in zip(sections, offsets):
vm = section.energy
@@ -202,186 +320,322 @@ def build_chords_track(
interval, quality = CHORD_PROGRESSION[ci]
for pitch in build_chord(root_midi + interval, quality):
notes.append(MidiNote(
pitch=pitch, start=bar * 4.0, duration=4.0,
pitch=pitch,
start=bar * 4.0,
duration=4.0,
velocity=int(80 * vm),
))
if notes:
clips.append(ClipDef(
position=sec_off * 4.0, length=section.bars * 4.0,
name=f"{section.name.capitalize()} Chords", midi_notes=notes,
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Chords",
midi_notes=notes,
))
plugins = [make_plugin("Omnisphere", 0)]
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("chords", []))]
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("chords", []))]
return TrackDef(
name="Chords", volume=VOLUME_LEVELS["chords"], pan=0.0,
color=ROLE_COLORS["chords"], clips=clips, plugins=plugins,
name="Chords",
volume=VOLUME_LEVELS["chords"],
pan=0.0,
color=ROLE_COLORS["chords"],
clips=clips,
plugins=plugins,
)
def build_melody_track(
analysis, sections, offsets, key_root, key_minor, seed=42,
def build_lead_track(
analysis,
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
seed: int = 42,
) -> TrackDef:
penta = get_pentatonic(key_root, key_minor, 4) + get_pentatonic(key_root, key_minor, 5)
"""Build the lead track — pentatonic melody, avoid transients, chord tones on strong beats."""
penta_low = get_pentatonic(key_root, key_minor, 4)
penta_high = get_pentatonic(key_root, key_minor, 5)
penta = penta_low + penta_high
transient_times = [t.time for t in analysis.transients]
beat_dur = 60.0 / analysis.bpm
def near_transient(beat: float, margin: float = 0.2) -> bool:
def near_transient(beat: float, margin_beats: float = 0.2) -> bool:
"""Return True if beat position is near a transient."""
s = beat * beat_dur
return any(abs(s - tt) < margin * beat_dur for tt in transient_times)
return any(abs(s - tt) < margin_beats * beat_dur for tt in transient_times)
rng = random.Random(seed)
clips = []
for section, sec_off in zip(sections, offsets):
vm = section.energy
# Density by section name
density_map = {"chorus": 0.6, "verse": 0.35, "build": 0.35, "intro": 0.2, "break": 0.2, "outro": 0.15}
density = density_map.get(section.name, 0.3)
notes = []
density = {"chorus": 0.6, "verse": 0.35, "build": 0.35}.get(section.name, 0.2)
for bar in range(section.bars):
for sixteenth in range(16):
bp = bar * 4.0 + sixteenth * 0.25
abs_bp = sec_off * 4.0 + bp
if rng.random() > density:
continue
if near_transient(sec_off * 4.0 + bp):
if near_transient(abs_bp, margin_beats=0.2):
continue
strong = sixteenth in (0, 8)
strong = sixteenth in (0, 8) # beat 0 and beat 2 of bar
# On strong beats: emphasize chord tones (1st, 3rd, 5th of pentatonic)
pool = [penta[0], penta[2], penta[4]] if strong else penta
notes.append(MidiNote(
pitch=rng.choice(pool), start=bp,
pitch=rng.choice(pool),
start=bp,
duration=0.5 if strong else 0.25,
velocity=int((90 if strong else 70) * vm),
))
if notes:
clips.append(ClipDef(
position=sec_off * 4.0, length=section.bars * 4.0,
name=f"{section.name.capitalize()} Melody", midi_notes=notes,
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Lead",
midi_notes=notes,
))
plugins = [make_plugin("Serum_2", 0)]
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("melody", []))]
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("lead", []))]
return TrackDef(
name="Melody", volume=VOLUME_LEVELS["melody"], pan=0.0,
color=ROLE_COLORS["melody"], clips=clips, plugins=plugins,
name="Lead",
volume=VOLUME_LEVELS["lead"],
pan=0.0,
color=ROLE_COLORS["lead"],
clips=clips,
plugins=plugins,
)
def build_vocal_track(
selector, sections, offsets, key, bpm, analysis,
def build_clap_track(
selector: SampleSelector,
sections: list[SectionDef],
offsets: list[float],
) -> TrackDef:
beat_dur = 60.0 / analysis.bpm
transient_times = sorted(t.time for t in analysis.transients)
used_ids: list[str] = []
clips = []
"""Build the clap track — audio snare samples at beats 1.0 and 3.5 ONLY in chorus/verse."""
# Get clap (snare) samples — select best one
snare_results = selector.select(role="snare", limit=5)
clap_path = snare_results[0].sample["original_path"] if snare_results else None
for section, sec_off in zip(sections, offsets):
char = "powerful" if section.name == "chorus" else "melodic"
vs = selector.select_diverse(
role="vocal", n=1, exclude=used_ids, key=key, bpm=bpm, character=char,
)
if not vs:
continue
vpath = vs[0]["original_path"]
sid = vs[0].get("file_hash", "")
if sid:
used_ids.append(sid)
if section.name == "chorus":
for bar in range(section.bars):
bar_start = (sec_off * 4.0 + bar * 4.0) * beat_dur
bar_end = bar_start + 4.0 * beat_dur
gap_start = bar_start
for tt in transient_times:
if tt < bar_start:
continue
if tt > bar_end:
break
if tt - gap_start > 0.08:
bp = sec_off * 4.0 + bar * 4.0 + (gap_start - bar_start) / beat_dur
lb = min((tt - gap_start) / beat_dur, 2.0)
clips.append(ClipDef(
position=bp, length=max(lb, 0.5),
name=f"{section.name.capitalize()} Vocal",
audio_path=vpath,
))
gap_start = tt
if bar_end - gap_start > 0.08:
bp = sec_off * 4.0 + bar * 4.0 + (gap_start - bar_start) / beat_dur
clips.append(ClipDef(
position=bp, length=max((bar_end - gap_start) / beat_dur, 0.5),
name=f"{section.name.capitalize()} Vocal", audio_path=vpath,
))
else:
for bar in range(0, section.bars, 4):
clips.append(ClipDef(
position=sec_off * 4.0 + bar * 4.0,
length=4.0 * min(4, section.bars - bar),
name=f"{section.name.capitalize()} Vocal",
audio_path=vpath, loop=True,
))
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("vocal", []))]
return TrackDef(
name="Vocals", volume=VOLUME_LEVELS["vocal"], pan=0.0,
color=ROLE_COLORS["vocal"], clips=clips, plugins=plugins,
)
def build_clap_track(selector, sections, offsets) -> TrackDef:
clap_results = selector.select(role="snare", limit=5)
clap_path = clap_results[0].sample["original_path"] if clap_results else None
clips = []
if clap_path:
for section, sec_off in zip(sections, offsets):
if section.name not in ("chorus", "verse"):
continue
for bar in range(section.bars):
for cb in CLAP_POSITIONS:
clips.append(ClipDef(
position=sec_off * 4.0 + bar * 4.0 + cb,
length=0.5, name=f"{section.name.capitalize()} Clap",
length=0.5,
name=f"{section.name.capitalize()} Clap",
audio_path=clap_path,
))
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("clap", []))]
return TrackDef(
name="Clap", volume=VOLUME_LEVELS["clap"], pan=0.0,
color=ROLE_COLORS["clap"], clips=clips,
name="Clap",
volume=VOLUME_LEVELS["clap"],
pan=0.0,
color=ROLE_COLORS["clap"],
clips=clips,
plugins=plugins,
)
def build_pad_track(sections, offsets, key_root, key_minor) -> TrackDef:
root_midi = root_to_midi(key_root, 3)
def build_pad_track(
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
) -> TrackDef:
"""Build the pad track — sustained root chord, one clip per section."""
root_midi = key_to_midi_root(key_root, 3)
quality = "minor" if key_minor else "major"
chord = build_chord(root_midi, quality)
clips = []
for section, sec_off in zip(sections, offsets):
vm = section.energy
notes = [MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(60 * vm)) for p in chord]
notes = [
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(60 * vm))
for p in chord
]
clips.append(ClipDef(
position=sec_off * 4.0, length=section.bars * 4.0,
name=f"{section.name.capitalize()} Pad", midi_notes=notes,
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Pad",
midi_notes=notes,
))
plugins = [make_plugin("Omnisphere", 0)]
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("pad", []))]
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("pad", []))]
return TrackDef(
name="Pad", volume=VOLUME_LEVELS["pad"], pan=0.0,
color=ROLE_COLORS["pad"], clips=clips, plugins=plugins,
name="Pad",
volume=VOLUME_LEVELS["pad"],
pan=0.0,
color=ROLE_COLORS["pad"],
clips=clips,
plugins=plugins,
)
# ---------------------------------------------------------------------------
# Return tracks + backward compat
# Phase 3: Mixing — Return tracks and sends
# ---------------------------------------------------------------------------
def create_return_tracks() -> list[TrackDef]:
"""Create Reverb and Delay return tracks."""
return [
TrackDef(
name="Reverb", volume=0.7, pan=0.0, clips=[],
name="Reverb",
volume=0.7,
pan=0.0,
clips=[],
plugins=[make_plugin("FabFilter_Pro-R_2", 0)],
),
TrackDef(
name="Delay", volume=0.7, pan=0.0, clips=[],
name="Delay",
volume=0.7,
pan=0.0,
clips=[],
plugins=[make_plugin("ValhallaDelay", 0)],
),
]
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Compose a REAPER .rpp project from drumloop analysis — instrumental only."
)
parser.add_argument("--bpm", type=float, default=None, help="BPM override")
parser.add_argument("--key", default=None, help="Key override (e.g. Am)")
parser.add_argument("--output", default="output/drumloop_v2.rpp", help="Output path")
parser.add_argument("--seed", type=int, default=None, help="Random seed")
args = parser.parse_args()
if args.seed is not None:
random.seed(args.seed)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# ===== Step 1: Select BEST drumloop (scored, not random) =====
index_path = _ROOT / "data" / "sample_index.json"
if not index_path.exists():
print(f"ERROR: sample index not found at {index_path}", file=sys.stderr)
sys.exit(1)
selector = SampleSelector(str(index_path))
selector._load()
# Filter drumloops in reggaeton tempo range (85-105 BPM)
candidates = [
s for s in selector._samples
if s["role"] == "drumloop" and 85 <= s["perceptual"]["tempo"] <= 105
]
if not candidates:
# Fallback: wider range
candidates = [
s for s in selector._samples
if s["role"] == "drumloop" and 80 <= s["perceptual"]["tempo"] <= 120
]
if not candidates:
print("ERROR: No suitable drumloops found", file=sys.stderr)
sys.exit(1)
# Score each candidate and pick the best
scored_candidates = []
for c in candidates:
analysis = DrumLoopAnalyzer(c["original_path"]).analyze()
c["_score"] = score_drumloop(c, analysis)
c["_analysis"] = analysis
scored_candidates.append(c)
best = max(scored_candidates, key=lambda x: x["_score"])
drumloop_path = best["original_path"]
analysis = best["_analysis"]
print(f"Selected drumloop: {best.get('original_name', drumloop_path)}")
print(f" Score: {best['_score']:.3f}")
print(f" BPM: {best['perceptual']['tempo']:.1f}, Key: {best['musical']['key']}")
print(f" Transients: {len(analysis.transients)} "
f"(kicks={len(analysis.transients_of_type('kick'))}, "
f"snares={len(analysis.transients_of_type('snare'))})")
# ===== Step 2: Project parameters (overrides win) =====
bpm = args.bpm if args.bpm is not None else analysis.bpm
key = args.key if args.key is not None else (analysis.key or "Am")
if bpm <= 0:
raise ValueError(f"bpm must be > 0, got {bpm}")
key_root, key_minor = parse_key(key)
print(f"\nProject: {bpm:.1f} BPM, Key: {key}")
# ===== Step 3: Build section structure =====
sections, offsets = build_section_structure()
# ===== Step 4: Build all tracks =====
total_beats = sum(s.bars for s in sections) * 4.0
tracks = [
build_drumloop_track(drumloop_path, total_beats),
build_bass_track(analysis, sections, offsets, key_root, key_minor),
build_chords_track(analysis, sections, offsets, key_root, key_minor),
build_lead_track(analysis, sections, offsets, key_root, key_minor, seed=args.seed or 42),
build_clap_track(selector, sections, offsets),
build_pad_track(sections, offsets, key_root, key_minor),
]
return_tracks = create_return_tracks()
all_tracks = tracks + return_tracks
# ===== Step 5: Wire sends =====
reverb_idx = len(tracks) # first return track
delay_idx = len(tracks) + 1 # second return track
for track in all_tracks:
if track.name in ("Reverb", "Delay"):
continue
role = track.name.lower()
sends = SEND_LEVELS.get(role, (0.0, 0.0))
track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]}
# ===== Step 6: Assemble SongDefinition =====
meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Drumloop Instrumental")
song = SongDefinition(
meta=meta,
tracks=all_tracks,
sections=sections,
master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
)
errors = song.validate()
if errors:
print("WARNING: validation errors:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
# ===== Step 7: Write RPP =====
builder = RPPBuilder(song, seed=args.seed)
builder.write(str(output_path))
print(f"\nWritten: {output_path.resolve()}")
# Backward compat stubs (used by tests)
EFFECT_ALIASES: dict = {}
def build_section_tracks(*args, **kwargs):
return [], []
def build_fx_chain(*args, **kwargs):
return []
@@ -390,124 +644,11 @@ def build_sampler_plugin(*args, **kwargs):
return None
def build_section_tracks(*args, **kwargs):
return [], []
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Compose a REAPER .rpp project from drumloop analysis."
)
parser.add_argument("--bpm", type=float, default=None, help="BPM override")
parser.add_argument("--key", default=None, help="Key override (e.g. Am)")
parser.add_argument("--output", default="output/drumloop_song.rpp", help="Output path")
parser.add_argument("--seed", type=int, default=None, help="Random seed")
args = parser.parse_args()
if args.seed is not None:
random.seed(args.seed)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Step 1: Select drumloop
index_path = _ROOT / "data" / "sample_index.json"
if not index_path.exists():
print(f"ERROR: sample index not found at {index_path}", file=sys.stderr)
sys.exit(1)
selector = SampleSelector(str(index_path))
selector._load()
drumloops = [
s for s in selector._samples
if s["role"] == "drumloop" and 85 <= s["perceptual"]["tempo"] <= 105
]
if not drumloops:
drumloops = [
s for s in selector._samples
if s["role"] == "drumloop" and 80 <= s["perceptual"]["tempo"] <= 120
]
if not drumloops:
print("ERROR: No suitable drumloops found", file=sys.stderr)
sys.exit(1)
drumloop = random.choice(drumloops)
drumloop_path = drumloop["original_path"]
print(f"Selected drumloop: {drumloop.get('original_name', drumloop_path)}")
print(f" BPM: {drumloop['perceptual']['tempo']:.1f}, Key: {drumloop['musical']['key']}")
# Step 2: Analyze drumloop
print("Analyzing drumloop...")
analyzer = DrumLoopAnalyzer(drumloop_path)
analysis = analyzer.analyze()
print(f" Detected BPM: {analysis.bpm:.1f}")
print(f" Detected Key: {analysis.key}")
print(f" Transients: {len(analysis.transients)} "
f"(kicks={len(analysis.transients_of_type('kick'))} "
f"snares={len(analysis.transients_of_type('snare'))} "
f"hihats={len(analysis.transients_of_type('hihat'))})")
# Step 3: Project parameters (overrides win)
bpm = args.bpm if args.bpm is not None else analysis.bpm
key = args.key if args.key is not None else (analysis.key or "Am")
if bpm <= 0:
raise ValueError(f"bpm must be > 0, got {bpm}")
key_root, key_minor = parse_key(key)
print(f"\nProject: {bpm:.1f} BPM, Key: {key}")
# Step 4: Section structure
sections = [SectionDef(name=n, bars=b, energy=e) for n, b, e in SECTIONS]
offsets = []
off = 0.0
for sec in sections:
offsets.append(off)
off += sec.bars
# Step 5: Build tracks
total_beats = sum(s.bars for s in sections) * 4.0
tracks = [
build_drumloop_track(drumloop_path, total_beats),
build_bass_track(analysis, sections, offsets, key_root, key_minor),
build_chords_track(analysis, sections, offsets, key_root, key_minor),
build_melody_track(analysis, sections, offsets, key_root, key_minor, seed=args.seed or 42),
build_vocal_track(selector, sections, offsets, key, bpm, analysis),
build_clap_track(selector, sections, offsets),
build_pad_track(sections, offsets, key_root, key_minor),
]
return_tracks = create_return_tracks()
all_tracks = tracks + return_tracks
# Step 6: Wire sends
reverb_idx = len(tracks)
delay_idx = len(tracks) + 1
for track in all_tracks:
if track.name not in ("Reverb", "Delay"):
role = track.name.lower().replace("vocals", "vocal")
sends = SEND_LEVELS.get(role, (0.0, 0.0))
track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]}
# Step 7: Assemble
meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Drumloop Track")
song = SongDefinition(
meta=meta, tracks=all_tracks, sections=sections,
master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
)
errors = song.validate()
if errors:
print("WARNING: validation errors:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
builder = RPPBuilder(song, seed=args.seed)
builder.write(str(output_path))
print(f"\nWritten: {output_path.resolve()}")
# Alias for renamed function
def build_melody_track(*args, **kwargs):
"""Backward compat alias — use build_lead_track instead."""
return build_lead_track(*args, **kwargs)
if __name__ == "__main__":
main()
main()

View File

@@ -157,7 +157,7 @@ class TestDrumloopFirstTracks:
def test_all_tracks_created(self, tmp_path):
output = _mock_main(tmp_path)
content = output.read_text(encoding="utf-8")
for name in ("Drumloop", "Bass", "Chords", "Melody", "Vocals", "Clap", "Pad", "Reverb", "Delay"):
for name in ("Drumloop", "Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
assert name in content, f"Expected track '{name}' in output"
def test_clap_on_dembow_beats(self, tmp_path):