- compose-test-sync: fix 3 failing tests (NOTE_TO_MIDI, DrumLoopAnalyzer mock, section name) - generate-song: CLI wrapper + RPP validator (6 structural checks) + 4 e2e tests - reascript-hybrid: ReaScriptGenerator + command protocol + CLI + 16 unit tests - 110/110 tests passing - Full SDD cycle (propose→spec→design→tasks→apply→verify) for all 3 changes
613 lines
20 KiB
Python
613 lines
20 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,
|
|
PluginDef, SectionDef,
|
|
)
|
|
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)}
|
|
|
|
# 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
|
|
"build": "filtrado", # building with filter
|
|
"chorus": "seco", # full energy dry
|
|
"break": "empty", # breakdown — no drumloop
|
|
"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),
|
|
("build", 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),
|
|
}
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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) -> PluginDef:
|
|
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)
|
|
|
|
|
|
def build_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
|
|
return sections, offsets
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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):
|
|
# Determine variant
|
|
section_key = section.name
|
|
variant = DRUMLOOP_ASSIGNMENTS.get(section_key, "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,
|
|
))
|
|
|
|
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,
|
|
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)):
|
|
# Perc in verse and chorus only, not intro/break/outro
|
|
if section.name in ("intro", "break", "bridge", "outro"):
|
|
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,
|
|
))
|
|
|
|
plugins = [make_plugin(fx, i) 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) -> TrackDef:
|
|
"""808 bass using PROVEN harmonic pattern from Ableton project."""
|
|
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
|
|
|
|
clips = []
|
|
for section, sec_off in zip(sections, offsets):
|
|
vm = section.energy
|
|
velocity = int(80 + 15 * vm) # 80-95 depending on energy
|
|
|
|
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,
|
|
))
|
|
|
|
if notes:
|
|
clips.append(ClipDef(
|
|
position=sec_off * 4.0,
|
|
length=section.bars * 4.0,
|
|
name=f"{section.name.capitalize()} 808",
|
|
midi_notes=notes,
|
|
))
|
|
|
|
plugins = [make_plugin(fx, i) 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) -> TrackDef:
|
|
"""Chords: i-VI-III-VII on downbeats, match key."""
|
|
root_midi = key_to_midi_root(key_root, 3)
|
|
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
|
|
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(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) 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: pentatonic, sparse, chord tones on strong beats."""
|
|
penta = get_pentatonic(key_root, key_minor, 4) + get_pentatonic(key_root, key_minor, 5)
|
|
rng = random.Random(seed)
|
|
clips = []
|
|
|
|
for section, sec_off in zip(sections, offsets):
|
|
# Lead only in chorus and final sections
|
|
if section.name not in ("chorus", "chorus2", "final"):
|
|
continue
|
|
|
|
vm = section.energy
|
|
density = 0.4
|
|
notes = []
|
|
|
|
for bar in range(section.bars):
|
|
for sixteenth in range(16):
|
|
bp = bar * 4.0 + sixteenth * 0.25
|
|
if rng.random() > density:
|
|
continue
|
|
strong = sixteenth in (0, 4, 8, 12) # quarter note positions
|
|
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((85 if strong else 65) * 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,
|
|
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.name.startswith(("chorus", "verse", "final")):
|
|
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,
|
|
clips=clips,
|
|
plugins=plugins,
|
|
)
|
|
|
|
|
|
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 in build, chorus, bridge, final only
|
|
if section.name not in ("build", "chorus", "chorus2", "bridge", "final"):
|
|
continue
|
|
|
|
vm = section.energy
|
|
notes = [
|
|
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(55 * 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,
|
|
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")
|
|
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 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),
|
|
build_lead_track(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
|
|
|
|
# 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")
|
|
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[: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)
|