Files
reaper-control/scripts/compose.py
renato97 7729d5f12f 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
2026-05-03 20:07:00 -03:00

654 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""Drumloop-first REAPER .rpp project generator for reggaeton instrumental.
The drumloop drives EVERYTHING: BPM, key, rhythm all come from analysis.
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/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
import argparse
import random
import sys
from pathlib import Path
_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(_ROOT))
from src.core.schema import (
SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote,
PluginDef, SectionDef,
)
from src.composer.drum_analyzer import DrumLoopAnalyzer
from src.selector import SampleSelector
from src.reaper_builder import RPPBuilder, PLUGIN_REGISTRY, PLUGIN_PRESETS
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
NOTE_TO_MIDI = {n: i for i, n in enumerate(NOTE_NAMES)}
ROLE_COLORS = {
"drumloop": 3,
"clap": 4,
"bass": 5,
"chords": 9,
"lead": 11,
"pad": 13,
}
# Section structure: (name, bars, energy, has_clap)
# Clap ONLY on chorus and verse sections
SECTIONS = [
("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"), # i
(8, "major"), # VI
(3, "major"), # III
(10, "major"), # VII
]
# FX chains per track role (before return sends)
FX_CHAINS = {
"drumloop": ["Decapitator", "Radiator"],
"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),
"chords": (0.15, 0.08),
"lead": (0.10, 0.05),
"clap": (0.05, 0.02),
"pad": (0.25, 0.15),
}
# Track volume levels
VOLUME_LEVELS = {
"drumloop": 0.85,
"bass": 0.82,
"chords": 0.70,
"lead": 0.75,
"clap": 0.80,
"pad": 0.65,
}
# 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
# ---------------------------------------------------------------------------
# Music theory helpers
# ---------------------------------------------------------------------------
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 get_pentatonic(root: str, is_minor: bool, octave: int) -> list[int]:
"""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] # minor pentatonic
else:
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]
# ---------------------------------------------------------------------------
# Plugin builder
# ---------------------------------------------------------------------------
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)
return PluginDef(name=registry_key, path=path, index=index, preset_data=preset)
return PluginDef(name=registry_key, path=registry_key, index=index)
# ---------------------------------------------------------------------------
# Phase 2: Track Generation
# ---------------------------------------------------------------------------
def build_drumloop_track(drumloop_path: str, total_beats: float) -> TrackDef:
"""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,
)
def build_bass_track(
analysis,
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
) -> TrackDef:
"""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(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 = []
for section, sec_off in zip(sections, offsets):
vm = section.energy
notes = []
for bar in range(section.bars):
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),
))
if notes:
clips.append(ClipDef(
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Bass",
midi_notes=notes,
))
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,
)
def build_chords_track(
analysis,
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
) -> TrackDef:
"""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
notes = []
for bar in range(section.bars):
ci = bar % len(CHORD_PROGRESSION)
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,
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,
))
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,
)
def build_lead_track(
analysis,
sections: list[SectionDef],
offsets: list[float],
key_root: str,
key_minor: bool,
seed: int = 42,
) -> TrackDef:
"""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_beats: float = 0.2) -> bool:
"""Return True if beat position is near a transient."""
s = beat * beat_dur
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 = []
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(abs_bp, margin_beats=0.2):
continue
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,
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()} Lead",
midi_notes=notes,
))
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("lead", []))]
return TrackDef(
name="Lead",
volume=VOLUME_LEVELS["lead"],
pan=0.0,
color=ROLE_COLORS["lead"],
clips=clips,
plugins=plugins,
)
def build_clap_track(
selector: SampleSelector,
sections: list[SectionDef],
offsets: list[float],
) -> TrackDef:
"""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
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",
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,
plugins=plugins,
)
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
]
clips.append(ClipDef(
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Pad",
midi_notes=notes,
))
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,
)
# ---------------------------------------------------------------------------
# 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=[],
plugins=[make_plugin("FabFilter_Pro-R_2", 0)],
),
TrackDef(
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 []
def build_sampler_plugin(*args, **kwargs):
return None
# 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()