#!/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]] = {} _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] 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) # 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), 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)