Files
reaper-control/scripts/compose.py
renato97 e99fa231dd fix: sidechain CC11 — pass kick_cache to build_bass_track + absolute position projection
Root cause: build_bass_track() never received the kick_cache in main().
Second issue: kick times were WAV-relative (0-12 beats) but bass expects
absolute positions (16+ beats). Added loop-duration projection to convert
relative → absolute positions across clip duration.

285 CC11 events now generated in output. 302/302 tests pass.
2026-05-04 00:26:03 -03:00

859 lines
31 KiB
Python

#!/usr/bin/env python
"""REAPER .rpp reggaeton generator — based on proven Ableton arrangement.
Uses REAL drumloop samples from the Ableton library (not scored random ones),
and a PROVEN harmonic bass pattern from a working Ableton project.
Drumloop arrangement: seco → filtrado → vacío → seco (repeating per section)
808 Bass: i - iv - i - V pattern (A1 → D2 → A1 → E2) from Ableton project
No vocals — instrumental only.
Usage:
python scripts/compose.py --output output/song.rpp
python scripts/compose.py --bpm 99 --key Am --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, CCEvent,
PluginDef, SectionDef,
)
from src.selector import SampleSelector
from src.reaper_builder import RPPBuilder, PLUGIN_REGISTRY, PLUGIN_PRESETS
from src.composer.chords import ChordEngine
from src.composer.melody_engine import build_motif, build_call_response
from src.calibrator import Calibrator
# ---------------------------------------------------------------------------
# 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)}
# Ableton drumloop paths (proven to sound good)
ABLETON_DRUMLOOP_DIR = Path(
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts"
r"\libreria\reggaeton\drumloops"
)
# Drumloop arrangement per section:
# Each section gets a drumloop variant: "seco", "filtrado", "empty", "seco"
# This cycles through the sections
DRUMLOOP_ASSIGNMENTS = {
"intro": "filtrado", # filtered intro
"verse": "seco", # dry verse
"pre-chorus": "filtrado", # building with filter
"chorus": "seco", # full energy dry
"verse2": "seco", # dry verse 2
"chorus2": "seco", # full energy dry
"bridge": "filtrado", # filtered bridge
"final": "seco", # full energy
"outro": "filtrado", # filtered outro
}
# Drumloop files for each variant
DRUMLOOP_FILES = {
"seco": [
"90bpm reggaeton antiguo drumloop.wav",
"94bpm reggaeton antiguo 2 drumloop.wav",
"100bpm_gata-only_drumloop.wav",
],
"filtrado": [
"100bpm filtrado drumloop.wav",
"100bpm contigo filtrado drumloop.wav",
],
}
# 808 Bass pattern from Ableton project (proven harmonic):
# i - iv - i - V in Am: A1(33) → D2(38) → A1(33) → E2(40)
# Duration: 1.5 beats, velocity varies by section
BASS_PATTERN_8BARS = [
# Bars 1-2: root (i)
{"pitch": 33, "start_time": 0.0, "duration": 1.5, "velocity": 80},
{"pitch": 33, "start_time": 2.0, "duration": 1.5, "velocity": 80},
{"pitch": 33, "start_time": 4.0, "duration": 1.5, "velocity": 80},
{"pitch": 33, "start_time": 6.0, "duration": 1.5, "velocity": 80},
# Bars 3-4: subdominant (iv)
{"pitch": 38, "start_time": 8.0, "duration": 1.5, "velocity": 80},
{"pitch": 38, "start_time": 10.0, "duration": 1.5, "velocity": 80},
{"pitch": 38, "start_time": 12.0, "duration": 1.5, "velocity": 80},
{"pitch": 38, "start_time": 14.0, "duration": 1.5, "velocity": 80},
# Bars 5-6: root (i)
{"pitch": 33, "start_time": 16.0, "duration": 1.5, "velocity": 80},
{"pitch": 33, "start_time": 18.0, "duration": 1.5, "velocity": 80},
{"pitch": 33, "start_time": 20.0, "duration": 1.5, "velocity": 80},
{"pitch": 33, "start_time": 22.0, "duration": 1.5, "velocity": 80},
# Bars 7-8: dominant (V)
{"pitch": 40, "start_time": 24.0, "duration": 1.5, "velocity": 80},
{"pitch": 40, "start_time": 26.0, "duration": 1.5, "velocity": 80},
{"pitch": 40, "start_time": 28.0, "duration": 1.5, "velocity": 80},
{"pitch": 40, "start_time": 30.0, "duration": 1.5, "velocity": 80},
]
# Section structure from Ableton project
SECTIONS = [
("intro", 4, 0.3, False),
("verse", 8, 0.5, True),
("pre-chorus", 4, 0.7, False),
("chorus", 8, 1.0, True),
("verse2", 8, 0.5, True),
("chorus2", 8, 1.0, True),
("bridge", 4, 0.4, False),
("final", 8, 1.0, True),
("outro", 4, 0.3, False),
]
# Clap positions: beats 2.0 and 3.5 in each bar (reggaeton dembow)
CLAP_POSITIONS = [2.0, 3.5]
# Chord progression i-VI-III-VII (reggaeton standard)
CHORD_PROGRESSION = [
(0, "minor"), # i
(8, "major"), # VI
(3, "major"), # III
(10, "major"), # VII
]
# FX chains per track role
FX_CHAINS = {
"drumloop": ["Decapitator", "Radiator"],
"bass": ["Serum_2", "Decapitator", "Gullfoss_Master"],
"chords": ["Omnisphere", "PhaseMistress", "EchoBoy"],
"lead": ["Serum_2", "Tremolator"],
"clap": ["Decapitator"],
"pad": ["Omnisphere", "ValhallaDelay"],
"perc": ["Decapitator"],
}
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),
"perc": (0.05, 0.02),
"transition_fx": (0.08, 0.05),
}
VOLUME_LEVELS = {
"drumloop": 0.85,
"bass": 0.72,
"chords": 0.70,
"lead": 0.75,
"clap": 0.80,
"pad": 0.65,
"perc": 0.78,
}
MASTER_VOLUME = 0.85
# ---------------------------------------------------------------------------
# FX Transitions — glue sections with risers, impacts, sweeps
# ---------------------------------------------------------------------------
FX_ROLE = "fx"
# Map: boundary section index → (type, start_offset, length, fade_in, fade_out)
# or list of tuples for boundaries with multiple FX (e.g. riser + impact).
# Position = offsets[boundary_idx] * 4 + start_offset
# Types: riser (before climax with long fade_in), impact (on downbeat with fade_out),
# sweep/transition (brief bridge, both fades)
FX_TRANSITIONS: dict[int, tuple | list[tuple]] = {
2: ("sweep", -2, 2, 0.3, 0.0), # verse→pre-chorus (beat 48 → pos 46)
3: [ # pre-chorus→chorus (beat 64)
("riser", -4, 4, 1.5, 0.0), # pos 60 — builds into chorus
("impact", 0, 2, 0.0, 0.3), # pos 64 — hits on chorus downbeat
],
4: ("transition", -2, 2, 0.2, 0.2), # chorus→verse2 (beat 96 → pos 94)
5: ("riser", -4, 4, 1.0, 0.0), # verse2→chorus2 (beat 128 → pos 124)
6: ("sweep", -2, 2, 0.2, 0.2), # chorus2→bridge (beat 160 → pos 158)
7: ("riser", -4, 4, 1.0, 0.0), # bridge→final (beat 176 → pos 172)
8: ("sweep", -2, 2, 0.3, 0.5), # final→outro (beat 208 → pos 206)
}
# Section energy — which tracks play in each section type
TRACK_ACTIVITY: dict[str, dict[str, bool]] = {
"intro": {"drumloop": True, "perc": False, "bass": False, "chords": False, "lead": False, "clap": False, "pad": True},
"verse": {"drumloop": True, "perc": False, "bass": True, "chords": True, "lead": False, "clap": False, "pad": False},
"pre-chorus": {"drumloop": True, "perc": False, "bass": True, "chords": True, "lead": True, "clap": True, "pad": False},
"chorus": {"drumloop": True, "perc": True, "bass": True, "chords": True, "lead": True, "clap": True, "pad": True},
"verse2": {"drumloop": True, "perc": False, "bass": True, "chords": True, "lead": False, "clap": False, "pad": False},
"chorus2": {"drumloop": True, "perc": True, "bass": True, "chords": True, "lead": True, "clap": True, "pad": True},
"bridge": {"drumloop": True, "perc": False, "bass": False, "chords": False, "lead": True, "clap": False, "pad": True},
"final": {"drumloop": True, "perc": True, "bass": True, "chords": True, "lead": True, "clap": True, "pad": True},
"outro": {"drumloop": True, "perc": False, "bass": False, "chords": False, "lead": False, "clap": False, "pad": True},
}
# (velocity_mult, vol_mult) per section type
SECTION_MULTIPLIERS: dict[str, tuple[float, float]] = {
"intro": (0.6, 0.70),
"verse": (0.7, 0.85),
"pre-chorus": (0.85, 0.95),
"chorus": (1.0, 1.00),
"verse2": (0.7, 0.85),
"chorus2": (1.0, 1.00),
"bridge": (0.6, 0.75),
"final": (1.0, 1.00),
"outro": (0.4, 0.60),
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def key_to_midi_root(key_str: str, octave: int = 2) -> int:
root = key_str.rstrip("m")
return NOTE_TO_MIDI[root] + (octave + 1) * 12
def parse_key(key_str: str) -> tuple[str, bool]:
if key_str.endswith("m"):
return key_str[:-1], True
return key_str, False
def build_chord(root_midi: int, quality: str) -> list[int]:
if quality == "minor":
return [root_midi, root_midi + 3, root_midi + 7]
return [root_midi, root_midi + 4, root_midi + 7]
def get_pentatonic(root: str, is_minor: bool, octave: int) -> list[int]:
root_midi = key_to_midi_root(root, octave)
intervals = [0, 3, 5, 7, 10] if is_minor else [0, 2, 4, 7, 9]
return [root_midi + i for i in intervals]
def make_plugin(registry_key: str, index: int, role: str = "") -> PluginDef:
if registry_key in PLUGIN_REGISTRY:
display, path, uid = PLUGIN_REGISTRY[registry_key]
preset = PLUGIN_PRESETS.get((registry_key, role)) or PLUGIN_PRESETS.get((registry_key, ""))
return PluginDef(name=registry_key, path=path, index=index, preset_data=preset, role=role)
return PluginDef(name=registry_key, path=registry_key, index=index, role=role)
def _section_active(section_name: str, role: str, activity: dict) -> bool:
"""Return whether a track role is active in the given section type."""
return activity.get(section_name, {}).get(role, False)
def build_section_structure():
sections = []
for n, b, e, _ in SECTIONS:
vm, vol = SECTION_MULTIPLIERS.get(n, (1.0, 1.0))
sections.append(SectionDef(name=n, bars=b, energy=e, velocity_mult=vm, vol_mult=vol))
offsets = []
off = 0.0
for sec in sections:
offsets.append(off)
off += sec.bars
return sections, offsets
# ---------------------------------------------------------------------------
# Sidechain kick cache
# ---------------------------------------------------------------------------
_kick_cache: dict[str, list[float]] = {}
_LOOP_DURATIONS: dict[str, float] = {}
_KICK_CONFIDENCE_THRESHOLD = 0.6
_CC11_DIP = 50
_CC11_HOLD = 0.02
_CC11_RELEASE = 0.18
def _get_kick_cache(drumloop_paths: list[str], bpm: float) -> dict[str, list[float]]:
"""Analyze drumloops, return {path: [kick_time_beats]}.
Uses module-level _kick_cache for deduplication across calls.
Kicks are filtered by confidence >= _KICK_CONFIDENCE_THRESHOLD.
Time is converted from seconds to beats using bpm.
"""
from src.composer.drum_analyzer import DrumLoopAnalyzer
for path in drumloop_paths:
if path in _kick_cache:
continue
try:
analyzer = DrumLoopAnalyzer(path)
analysis = analyzer.analyze()
kicks = [
t for t in analysis.transients
if t.type == "kick" and t.confidence >= _KICK_CONFIDENCE_THRESHOLD
]
beat_dur = 60.0 / bpm
_kick_cache[path] = [t.time / beat_dur for t in kicks]
_LOOP_DURATIONS[path] = analysis.duration / beat_dur
except Exception:
_kick_cache[path] = []
return _kick_cache
# ---------------------------------------------------------------------------
# Track Builders
# ---------------------------------------------------------------------------
def pick_drumloop(variant: str, index: int = 0) -> str | None:
"""Pick a drumloop file for the given variant (seco/filtrado)."""
files = DRUMLOOP_FILES.get(variant, [])
if not files:
return None
name = files[index % len(files)]
path = ABLETON_DRUMLOOP_DIR / name
return str(path) if path.exists() else None
def build_drumloop_track(sections, offsets, seed: int = 0) -> TrackDef:
"""Drumloop track: different sample per section (seco/filtrado/empty cycle)."""
rng = random.Random(seed)
clips = []
seco_idx = 0
filtrado_idx = 0
for section, sec_off in zip(sections, offsets):
# Skip sections where drumloop is not active
if not _section_active(section.name, "drumloop", TRACK_ACTIVITY):
continue
# Determine variant
variant = DRUMLOOP_ASSIGNMENTS.get(section.name, "seco")
if variant == "empty":
continue # no drumloop in this section
if variant == "seco":
path = pick_drumloop("seco", seco_idx)
seco_idx += 1
else:
path = pick_drumloop("filtrado", filtrado_idx)
filtrado_idx += 1
if path:
clips.append(ClipDef(
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Drumloop",
audio_path=path,
loop=True,
vol_mult=section.vol_mult,
))
plugins = [make_plugin(fx, i, role="drumloop") for i, fx in enumerate(FX_CHAINS.get("drumloop", []))]
return TrackDef(
name="Drumloop",
volume=VOLUME_LEVELS["drumloop"],
pan=0.0,
clips=clips,
plugins=plugins,
)
def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef:
"""Percussion track: perc loops from Ableton library per section."""
ableton_perc_dir = Path(
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts"
r"\libreria\reggaeton\drumloops"
)
# Use specific perc files from Ableton project
perc_files = [
"91bpm bellako percloop.wav",
"91bpm bellako percloop.wav", # repeat for variety
]
# Also check SentimientoLatino perc
sentimiento_perc = Path(
r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts"
r"\libreria\reggaeton\SentimientoLatino2025\02\23 Drum Loops"
)
clips = []
for i, (section, sec_off) in enumerate(zip(sections, offsets)):
# Use centralized activity matrix instead of ad-hoc name check
if not _section_active(section.name, "perc", TRACK_ACTIVITY):
continue
perc_name = perc_files[i % len(perc_files)]
perc_path = ableton_perc_dir / perc_name
if perc_path.exists():
clips.append(ClipDef(
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Perc",
audio_path=str(perc_path),
loop=True,
vol_mult=section.vol_mult,
))
plugins = [make_plugin(fx, i, role="perc") for i, fx in enumerate(FX_CHAINS.get("perc", []))]
return TrackDef(
name="Perc",
volume=VOLUME_LEVELS["perc"],
pan=0.12,
clips=clips,
plugins=plugins,
)
def build_bass_track(sections, offsets, key_root: str, key_minor: bool,
kick_cache: dict[str, list[float]] | None = None) -> TrackDef:
"""808 bass using PROVEN harmonic pattern from Ableton project.
When kick_cache is provided, generates CC11 ducking events on kick hits.
"""
root_midi = key_to_midi_root(key_root, 1) # Octave 1 for 808
# Transpose the Ableton pattern to match the project key
# Ableton pattern is in Am (root=33=A1), transpose to project key
transpose = root_midi - 33 # 33 is A1 from Ableton pattern
kick_cache = kick_cache or {}
clips = []
for section, sec_off in zip(sections, offsets):
if not _section_active(section.name, "bass", TRACK_ACTIVITY):
continue
velocity = int(80 * section.velocity_mult)
notes = []
bars = section.bars
# Repeat the 8-bar pattern to fill the section
pattern_notes = BASS_PATTERN_8BARS
for repeat_start in range(0, bars, 8):
bars_this_repeat = min(8, bars - repeat_start)
for pn in pattern_notes:
# Only include notes within this repeat's bar range
bar_of_note = pn["start_time"] / 4.0
if bar_of_note < bars_this_repeat:
notes.append(MidiNote(
pitch=pn["pitch"] + transpose,
start=repeat_start * 4.0 + pn["start_time"],
duration=pn["duration"],
velocity=velocity,
))
# Generate CC11 ducking events from kick cache
cc_events: list[CCEvent] = []
clip_start = sec_off * 4.0
clip_end = clip_start + section.bars * 4.0
# Collect all kick positions in this clip's time range
clip_kicks: list[float] = []
for drumloop_path, kicks in kick_cache.items():
for kick_beat in kicks:
if clip_start <= kick_beat < clip_end:
clip_kicks.append((kick_beat - clip_start)) # relative to clip start
clip_kicks.sort()
for rel_kick in clip_kicks:
cc_events.append(CCEvent(controller=11, time=rel_kick, value=_CC11_DIP))
cc_events.append(CCEvent(controller=11, time=rel_kick + _CC11_HOLD, value=_CC11_DIP))
cc_events.append(CCEvent(controller=11, time=rel_kick + _CC11_RELEASE, value=127))
if notes:
clips.append(ClipDef(
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} 808",
midi_notes=notes,
midi_cc=cc_events,
vol_mult=section.vol_mult,
))
plugins = [make_plugin(fx, i, role="bass") for i, fx in enumerate(FX_CHAINS.get("bass", []))]
return TrackDef(
name="808 Bass",
volume=VOLUME_LEVELS["bass"],
pan=0.0,
clips=clips,
plugins=plugins,
)
def build_chords_track(
sections, offsets, key_root: str, key_minor: bool,
emotion: str = "romantic", inversion: str = "root",
) -> TrackDef:
"""Chords: delegate to ChordEngine for progression + voice leading."""
key = key_root + "m" if key_minor else key_root
engine = ChordEngine(key, seed=42)
clips = []
for section, sec_off in zip(sections, offsets):
if section.name in ("intro", "break", "outro"):
continue # no chords in sparse sections
vm = section.energy
voicings = engine.progression(
section.bars, emotion=emotion,
beats_per_chord=4, inversion=inversion,
)
notes = []
for bar in range(section.bars):
chord_idx = bar % len(voicings)
voicing = voicings[chord_idx]
for pitch in voicing:
notes.append(MidiNote(
pitch=pitch,
start=bar * 4.0,
duration=4.0,
velocity=int(75 * 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, role="chords") for i, fx in enumerate(FX_CHAINS.get("chords", []))]
return TrackDef(
name="Chords",
volume=VOLUME_LEVELS["chords"],
pan=0.0,
clips=clips,
plugins=plugins,
)
def build_lead_track(sections, offsets, key_root: str, key_minor: bool, seed: int = 0) -> TrackDef:
"""Lead melody: hook-based call-response using melody_engine.
Replaces random pentatonic generation with deterministic motif engine
producing arch-contour hooks, chord-tone emphasis, and call-response phrasing.
"""
clips = []
for section, sec_off in zip(sections, offsets):
# Lead only in sections where the lead role is active
if not _section_active(section.name, "lead", TRACK_ACTIVITY):
continue
# Build a hook motif for this section (4 bars), then expand to section length
motif = build_motif(key_root, key_minor, "hook", bars=min(4, section.bars), seed=seed)
notes = build_call_response(motif, bars=section.bars, key_root=key_root,
key_minor=key_minor, seed=seed + 1)
# Scale velocities to section energy
for note in notes:
note.velocity = int(note.velocity * section.velocity_mult)
if notes:
clips.append(ClipDef(
position=sec_off * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} Lead",
midi_notes=notes,
vol_mult=section.vol_mult,
))
plugins = [make_plugin(fx, i, role="lead") for i, fx in enumerate(FX_CHAINS.get("lead", []))]
return TrackDef(
name="Lead",
volume=VOLUME_LEVELS["lead"],
pan=0.0,
clips=clips,
plugins=plugins,
)
def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef:
"""Clap: snare samples on beats 2.0 and 3.5, ONLY in chorus/verse sections."""
snare_results = selector.select(role="snare", limit=3)
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 not _section_active(section.name, "clap", TRACK_ACTIVITY):
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,
vol_mult=section.vol_mult,
))
plugins = [make_plugin(fx, i, role="clap") for i, fx in enumerate(FX_CHAINS.get("clap", []))]
return TrackDef(
name="Clap",
volume=VOLUME_LEVELS["clap"],
pan=0.0,
clips=clips,
plugins=plugins,
)
def build_fx_track(
sections, offsets, selector: SampleSelector, seed: int = 0,
) -> TrackDef:
"""Build a dedicated transition FX track with audio clips at section boundaries.
Uses SampleSelector.select_one(role="fx") to pick FX samples from the library.
FX_TRANSITIONS maps boundary section indices to (type, offset, length, fade_in, fade_out).
Boundary 3 (pre-chorus→chorus) has two entries: riser BEFORE the boundary and impact ON it.
Clip positions are computed as: offsets[boundary_idx] * 4 + start_offset.
Risers have fade_in > 0 (build-up); impacts have fade_out > 0 (hit); sweeps have both.
"""
clips = []
fx_idx = 0
for boundary_idx, entries in sorted(FX_TRANSITIONS.items()):
# Normalise: single tuple → list of tuples
items = [entries] if isinstance(entries, tuple) else entries
for fx_type, start_offset, length, fade_in, fade_out in items:
boundary_beat = offsets[boundary_idx] * 4.0
position = boundary_beat + start_offset
# Select one FX sample per clip for variety
sample = selector.select_one(role=FX_ROLE, seed=seed + fx_idx)
fx_idx += 1
audio_path = sample.get("original_path") if sample else None
clips.append(ClipDef(
position=position,
length=float(length),
name=f"{fx_type.capitalize()} FX",
audio_path=audio_path,
fade_in=float(fade_in),
fade_out=float(fade_out),
))
return TrackDef(
name="Transition FX",
volume=0.72,
pan=0.0,
clips=clips,
send_level={0: 0.08, 1: 0.05},
)
def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackDef:
"""Pad: sustained root chord, only in chorus/build sections."""
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):
# Pad only where the pad role is active
if not _section_active(section.name, "pad", TRACK_ACTIVITY):
continue
velocity = int(55 * section.velocity_mult)
notes = [
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=velocity)
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,
vol_mult=section.vol_mult,
))
plugins = [make_plugin(fx, i, role="pad") for i, fx in enumerate(FX_CHAINS.get("pad", []))]
return TrackDef(
name="Pad",
volume=VOLUME_LEVELS["pad"],
pan=0.0,
clips=clips,
plugins=plugins,
)
def create_return_tracks() -> list[TrackDef]:
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 reggaeton — based on proven Ableton arrangement."
)
parser.add_argument("--bpm", type=float, default=99, help="BPM (default: 99)")
parser.add_argument("--key", default="Am", help="Key (default: Am)")
parser.add_argument("--output", default="output/song.rpp", help="Output path")
parser.add_argument("--seed", type=int, default=None, help="Random seed")
parser.add_argument(
"--emotion", default="romantic",
choices=["romantic", "dark", "club", "classic"],
help="Emotion mode for chord progression (default: romantic)",
)
parser.add_argument(
"--inversion", default="root",
choices=["root", "first", "second"],
help="Chord inversion preference (default: root)",
)
parser.add_argument(
"--no-calibrate", action="store_true",
help="Skip post-processing mix calibration",
)
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)
bpm = args.bpm
if bpm <= 0:
raise ValueError(f"bpm must be > 0, got {bpm}")
key = args.key
key_root, key_minor = parse_key(key)
print(f"Project: {bpm} BPM, Key: {key}")
# Load sample selector for clap/snare
index_path = _ROOT / "data" / "sample_index.json"
selector = SampleSelector(str(index_path))
selector._load()
# Build sections
sections, offsets = build_section_structure()
total_beats = sum(s.bars for s in sections) * 4.0
print(f"Sections: {len(sections)}, Total: {int(total_beats/4)} bars ({total_beats} beats)")
# Build kick cache from unique drumloop paths
drumloop_track = build_drumloop_track(sections, offsets, seed=args.seed or 0)
drumloop_paths = sorted({c.audio_path for c in drumloop_track.clips if c.audio_path})
if drumloop_paths:
_get_kick_cache(drumloop_paths, bpm)
for path, kicks in _kick_cache.items():
print(f" Kick cache: {len(kicks)} kicks in {Path(path).name}")
# Project relative kicks to absolute timeline positions per clip
_abs_kicks: dict[str, list[float]] = {}
for clip in drumloop_track.clips:
path = clip.audio_path
if not path or path not in _kick_cache:
continue
rel_kicks = _kick_cache[path]
if not rel_kicks:
continue
loop_beats = _LOOP_DURATIONS.get(path, 8.0)
abs_positions: list[float] = []
loop_n = 0
while loop_n * loop_beats < clip.length:
offset = clip.position + loop_n * loop_beats
for k in rel_kicks:
abs_pos = offset + k
if abs_pos < clip.position + clip.length:
abs_positions.append(abs_pos)
loop_n += 1
if path in _abs_kicks:
_abs_kicks[path].extend(abs_positions)
else:
_abs_kicks[path] = abs_positions
_kick_cache.clear()
_kick_cache.update(_abs_kicks)
# Build tracks
tracks = [
build_drumloop_track(sections, offsets, seed=args.seed or 0),
build_perc_track(sections, offsets, seed=args.seed or 0),
build_bass_track(sections, offsets, key_root, key_minor, kick_cache=_kick_cache),
build_chords_track(sections, offsets, key_root, key_minor,
emotion=args.emotion, inversion=args.inversion),
build_lead_track(sections, offsets, key_root, key_minor, seed=args.seed or 42),
build_clap_track(selector, sections, offsets),
build_fx_track(sections, offsets, selector, seed=args.seed or 0),
build_pad_track(sections, offsets, key_root, key_minor),
]
return_tracks = create_return_tracks()
all_tracks = tracks + return_tracks
# Wire sends
reverb_idx = len(tracks)
delay_idx = len(tracks) + 1
for track in all_tracks:
if track.name in ("Reverb", "Delay"):
continue
role = track.name.lower().replace(" ", "_")
sends = SEND_LEVELS.get(role, (0.0, 0.0))
track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]}
# Assemble
meta = SongMeta(
bpm=bpm, key=key, title="Reggaeton Instrumental",
calibrate=not args.no_calibrate,
)
song = SongDefinition(
meta=meta,
tracks=all_tracks,
sections=sections,
master_plugins=["Pro-Q_3", "Pro-C_2", "Pro-L_2"],
)
# Post-processing mix calibration (unless --no-calibrate)
if meta.calibrate:
Calibrator.apply(song)
errors = song.validate()
if errors:
print("WARNING: validation errors:", file=sys.stderr)
for e in errors[:10]:
print(f" - {e}", file=sys.stderr)
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
def build_melody_track(sections, offsets, key_root, key_minor, seed=0):
return build_lead_track(sections, offsets, key_root, key_minor, seed=seed)