feat: SDD workflow — test sync, song generation + validation, ReaScript hybrid pipeline

- 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
This commit is contained in:
renato97
2026-05-03 22:00:26 -03:00
parent 7729d5f12f
commit 48bc271afc
25 changed files with 2842 additions and 343 deletions

View File

@@ -1,14 +1,16 @@
#!/usr/bin/env python
"""Drumloop-first REAPER .rpp project generator for reggaeton instrumental.
"""REAPER .rpp reggaeton generator — based on proven Ableton arrangement.
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.
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/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
python scripts/compose.py --output output/song.rpp
python scripts/compose.py --bpm 99 --key Am --output output/song.rpp
"""
from __future__ import annotations
@@ -24,11 +26,9 @@ 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
# ---------------------------------------------------------------------------
@@ -36,34 +36,83 @@ from src.reaper_builder import RPPBuilder, PLUGIN_REGISTRY, PLUGIN_PRESETS
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,
# 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
}
# 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),
# 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},
]
# Tresillo rhythm positions in beats (within a bar)
TRESILLO_POSITIONS = [0.0, 0.75, 1.5, 2.0, 2.75, 3.5]
# 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 in beats (within a bar)
CLAP_POSITIONS = [1.0, 3.5]
# Clap positions: beats 2.0 and 3.5 in each bar (reggaeton dembow)
CLAP_POSITIONS = [2.0, 3.5]
# i-VI-III-VII chord progression in semitones from root (minor key)
# Chord progression i-VI-III-VII (reggaeton standard)
CHORD_PROGRESSION = [
(0, "minor"), # i
(8, "major"), # VI
@@ -71,7 +120,7 @@ CHORD_PROGRESSION = [
(10, "major"), # VII
]
# FX chains per track role (before return sends)
# FX chains per track role
FX_CHAINS = {
"drumloop": ["Decapitator", "Radiator"],
"bass": ["Serum_2", "Decapitator", "Gullfoss_Master"],
@@ -79,83 +128,67 @@ FX_CHAINS = {
"lead": ["Serum_2", "Tremolator"],
"clap": ["Decapitator"],
"pad": ["Omnisphere", "ValhallaDelay"],
"perc": ["Decapitator"],
}
# 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),
"perc": (0.05, 0.02),
}
# Track volume levels
VOLUME_LEVELS = {
"drumloop": 0.85,
"bass": 0.82,
"bass": 0.72,
"chords": 0.70,
"lead": 0.75,
"clap": 0.80,
"pad": 0.65,
"perc": 0.78,
}
# Master volume
MASTER_VOLUME = 0.85
# ---------------------------------------------------------------------------
# Phase 1: Infrastructure
# Helpers
# ---------------------------------------------------------------------------
def score_drumloop(sample: dict, analysis) -> float:
"""Score a drumloop candidate for selection quality.
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
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
def parse_key(key_str: str) -> tuple[str, bool]:
if key_str.endswith("m"):
return key_str[:-1], True
return key_str, False
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)
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]
# 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)
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]
# 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 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():
"""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
@@ -165,154 +198,163 @@ def build_section_structure():
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
# Track Builders
# ---------------------------------------------------------------------------
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 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 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_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")
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]
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
# ---------------------------------------------------------------------------
# Plugin builder
# ---------------------------------------------------------------------------
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,
))
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 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"
)
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 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 = []
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)
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=root_midi,
start=bar * 4.0 + pos,
duration=0.5,
velocity=int(100 * vm),
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()} Bass",
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="Bass",
name="808 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."""
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):
@@ -323,7 +365,7 @@ def build_chords_track(
pitch=pitch,
start=bar * 4.0,
duration=4.0,
velocity=int(80 * vm),
velocity=int(75 * vm),
))
if notes:
clips.append(ClipDef(
@@ -338,60 +380,38 @@ def build_chords_track(
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)
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):
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)
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
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)
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((90 if strong else 70) * vm),
velocity=int((85 if strong else 65) * vm),
))
if notes:
@@ -407,26 +427,20 @@ def build_lead_track(
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)
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 section.name not in ("chorus", "verse"):
if not section.name.startswith(("chorus", "verse", "final")):
continue
for bar in range(section.bars):
for cb in CLAP_POSITIONS:
@@ -442,28 +456,26 @@ def build_clap_track(
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."""
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(60 * vm))
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(55 * vm))
for p in chord
]
clips.append(ClipDef(
@@ -478,18 +490,12 @@ def build_pad_track(
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",
@@ -514,11 +520,11 @@ def create_return_tracks() -> list[TrackDef]:
def main() -> None:
parser = argparse.ArgumentParser(
description="Compose a REAPER .rpp project from drumloop analysis — instrumental only."
description="Compose a REAPER .rpp reggaeton — based on proven Ableton arrangement."
)
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("--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()
@@ -528,69 +534,31 @@ def main() -> None:
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)
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()
# 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 =====
# Build sections
sections, offsets = build_section_structure()
# ===== Step 4: Build all tracks =====
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(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_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),
]
@@ -598,18 +566,18 @@ def main() -> None:
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
# 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()
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]}
# ===== Step 6: Assemble SongDefinition =====
meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Drumloop Instrumental")
# Assemble
meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Instrumental")
song = SongDefinition(
meta=meta,
tracks=all_tracks,
@@ -620,10 +588,9 @@ def main() -> None:
errors = song.validate()
if errors:
print("WARNING: validation errors:", file=sys.stderr)
for e in errors:
for e in errors[:10]:
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()}")
@@ -635,20 +602,11 @@ 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()
def build_melody_track(sections, offsets, key_root, key_minor, seed=0):
return build_lead_track(sections, offsets, key_root, key_minor, seed=seed)

63
scripts/generate.py Normal file
View File

@@ -0,0 +1,63 @@
"""REAPER .rpp song generator — thin CLI wrapper around compose.main().
Usage:
python scripts/generate.py --bpm 95 --key Am --output output/song.rpp --seed 42
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(_ROOT))
import compose
def main() -> None:
parser = argparse.ArgumentParser(description="Generate a REAPER .rpp reggaeton song.")
parser.add_argument("--bpm", type=float, default=95, help="BPM (default: 95)")
parser.add_argument("--key", default="Am", help="Musical key (default: Am)")
parser.add_argument(
"--output", default="output/song.rpp", help="Output .rpp path (default: output/song.rpp)"
)
parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
parser.add_argument(
"--validate", action="store_true", help="Run validator after generation"
)
args = parser.parse_args()
# BPM validation
if args.bpm <= 0:
raise ValueError("bpm must be > 0")
# Ensure output directory exists
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Delegate to compose.main() — set sys.argv so compose's argparse works
sys.argv = [
sys.argv[0],
"--bpm", str(args.bpm),
"--key", args.key,
"--output", str(output_path),
"--seed", str(args.seed),
]
compose.main()
# Post-generation validation
if args.validate:
from src.validator.rpp_validator import validate_rpp_output
errors = validate_rpp_output(str(output_path), expected_bpm=args.bpm, expected_bars=52)
if errors:
print("Validation errors:", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
sys.exit(1)
print("Validation passed.", file=sys.stderr)
if __name__ == "__main__":
main()

145
scripts/run_in_reaper.py Normal file
View File

@@ -0,0 +1,145 @@
"""CLI to run Phase 2 ReaScript refinement on a .rpp file.
Usage: python scripts/run_in_reaper.py <rpp_path> [--output <wav_path>] [--timeout <seconds>]
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
from src.reaper_scripting import ReaScriptGenerator
from src.reaper_scripting.commands import (
ReaScriptCommand,
ReaScriptResult,
read_result,
write_command,
)
def main():
parser = argparse.ArgumentParser(
description="Run Phase 2 ReaScript refinement on a .rpp file"
)
parser.add_argument("rpp_path", help="Path to .rpp file")
parser.add_argument(
"--output", "-o", help="Path for rendered WAV output", default=None
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Timeout in seconds for polling result (default: 120)",
)
args = parser.parse_args()
rpp_path = Path(args.rpp_path)
if not rpp_path.exists():
print(f"ERROR: .rpp file not found: {rpp_path}", file=sys.stderr)
sys.exit(1)
# Resolve render_path: default to .rpp folder with _rendered.wav
if args.output:
render_path = Path(args.output)
else:
render_path = rpp_path.parent / f"{rpp_path.stem}_rendered.wav"
# 1. Build command
command = ReaScriptCommand(
version=1,
action="calibrate",
rpp_path=str(rpp_path.resolve()),
render_path=str(render_path.resolve()),
timeout=args.timeout,
track_calibration=[],
)
# 2. Generate ReaScript
generator = ReaScriptGenerator()
# Write to a fixed path - in real usage this would be REAPER ResourcePath()/scripts/
reaper_scripts_dir = Path(sys.path[1]) / "scripts" if len(sys.path) > 1 else Path("scripts")
script_path = reaper_scripts_dir / "fl_control_phase2.py"
generator.generate(script_path, command)
print(f"Generated ReaScript: {script_path}")
# 3. Write command JSON (same directory as script)
cmd_json_path = reaper_scripts_dir / "fl_control_command.json"
write_command(cmd_json_path, command)
print(f"Wrote command JSON: {cmd_json_path}")
# 4. Poll for result JSON
res_json_path = reaper_scripts_dir / "fl_control_result.json"
timeout = args.timeout
poll_interval = 2.0
elapsed = 0.0
print(f"Polling for result at {res_json_path} (timeout={timeout}s)...")
while elapsed < timeout:
if res_json_path.exists():
break
time.sleep(poll_interval)
elapsed += poll_interval
if not res_json_path.exists():
print("TIMEOUT: result JSON not found within timeout", file=sys.stderr)
sys.exit(2)
# 5. Read and print results
try:
result = read_result(res_json_path)
except Exception as e:
print(f"ERROR: failed to read result: {e}", file=sys.stderr)
sys.exit(1)
if result.status == "error":
print(f"REAPER error: {result.message}", file=sys.stderr)
sys.exit(1)
# Print LUFS metrics
print("\n=== Phase 2 Result ===")
print(f"Status: {result.status}")
if result.lufs is not None:
print(f"LUFS: {result.lufs}")
if result.integrated_lufs is not None:
print(f"Integrated LUFS: {result.integrated_lufs}")
if result.short_term_lufs is not None:
print(f"Short-term LUFS: {result.short_term_lufs}")
print(f"Tracks verified: {result.tracks_verified}")
if result.fx_errors:
print(f"FX errors: {json.dumps(result.fx_errors, indent=2)}")
print(f"Message: {result.message}")
# 6. Write output files
output_dir = rpp_path.parent / "output"
output_dir.mkdir(exist_ok=True)
# LUFS metrics
lufs_data = {
"lufs": result.lufs,
"integrated_lufs": result.integrated_lufs,
"short_term_lufs": result.short_term_lufs,
"render_path": str(render_path.resolve()),
}
lufs_path = output_dir / f"{rpp_path.stem}_lufs.json"
with open(lufs_path, "w", encoding="utf-8") as f:
json.dump(lufs_data, f, indent=2)
print(f"Wrote LUFS data: {lufs_path}")
# FX errors
if result.fx_errors:
fx_path = output_dir / f"{rpp_path.stem}_fx_errors.json"
with open(fx_path, "w", encoding="utf-8") as f:
json.dump(result.fx_errors, f, indent=2)
print(f"Wrote FX errors: {fx_path}")
print("\nPhase 2 complete.")
sys.exit(0)
if __name__ == "__main__":
main()