feat: full reggaeton song generator with master chain, sends, clap, vocal, drumloop

- Add master FXCHAIN with Pro-Q 3, Pro-C 2, Pro-L 2 on master track
- Add AUXRECV sends routing to Reverb/Delay return tracks
- Add clap track with CLAP_DEMBOW pattern
- Add vocal track with sample selection per section
- Add drumloop layer with loop detection
- Add track colors per role for visual organization
- Randomize chord progressions from genre config (5 options)
- Add master_plugins and send_level fields to schema
- Add _build_master_fxchain() and AUXRECV rendering to RPPBuilder
- 72 tests passing, RPP generates with 12 tracks, 18 sends, 20 plugins
This commit is contained in:
renato97
2026-05-03 19:13:10 -03:00
parent 8562bfbed1
commit 672607c356
5 changed files with 18157 additions and 14 deletions

17990
output/full_reggaeton_song.rpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -73,10 +73,24 @@ ROLE_RHYTHM_GENERATORS = {
"snare": "snare_pattern_bank_notes",
"hihat": "hihat_pattern_bank_notes",
"perc": "perc_combo_notes",
"clap": "clap_24_notes",
}
# Roles that use audio items per hit instead of MIDI pattern
AUDIO_ROLES = {"drums", "snare", "hihat", "perc"}
AUDIO_ROLES = {"drums", "snare", "hihat", "perc", "clap", "drumloop", "vocal"}
# Role → color index mapping (REAPER color palette 0-67)
ROLE_COLORS: dict[str, int] = {
"drums": 3, # red
"clap": 4, # orange-red
"bass": 5, # orange
"harmony": 9, # green
"lead": 11, # cyan
"pad": 13, # blue
"perc": 7, # yellow
"vocal": 15, # pink/magenta
"drumloop": 3, # red (same as drums)
}
# Role → sample key (used for SampleSelector)
ROLE_TO_SAMPLE_ROLE = {
@@ -88,6 +102,9 @@ ROLE_TO_SAMPLE_ROLE = {
"lead": "lead",
"harmony": "keys",
"pad": "pad",
"clap": "snare", # clap uses snare samples (no dedicated clap role)
"vocal": "vocal",
"drumloop": "drumloop",
}
@@ -238,6 +255,12 @@ def build_section_tracks(
("trapico", 1),
]
# Add extended roles (clap, vocal, drumloop) if not already in roles
# These are handled as audio roles with special sample selection
for _role in ("clap", "vocal", "drumloop"):
if _role not in roles:
roles[_role] = {}
# Parse sections into SectionDef list
sections: list[SectionDef] = []
for s in sections_data:
@@ -339,6 +362,36 @@ def build_section_tracks(
midi_notes=midi_notes,
)
section_clips.append(clip)
else:
# vocal, drumloop: audio clips spanning full sections
if role in ("vocal", "drumloop") and sample_path:
# Select character based on section type for vocal
if role == "vocal":
if section.name in ("verse", "bridge"):
character = "melodic"
elif section.name in ("chorus", "drop"):
character = "powerful"
else:
character = "neutral"
vocal_samples = selector.select_diverse(
role="vocal", n=1, exclude=used_sample_ids.get("vocal", []),
key=key, bpm=bpm, character=character
)
if vocal_samples:
sample = vocal_samples[0]
sample_path = sample.get("original_path")
sample_id = sample.get("file_hash", "")
if sample_id:
used_sample_ids.setdefault("vocal", []).append(sample_id)
audio_clip = ClipDef(
position=sec_offset * 4.0,
length=section.bars * 4.0,
name=f"{section.name.capitalize()} {role.capitalize()}",
audio_path=sample_path,
loop=(role == "drumloop"),
)
section_clips.append(audio_clip)
if not section_clips:
continue
@@ -361,11 +414,14 @@ def build_section_tracks(
send_reverb = 0.3 if per_role_cfg.get("reverb_on_lead") or per_role_cfg.get("reverb_on_snare") else 0.0
send_delay = 0.0
# Apply role color (REAPER color palette 0-67)
role_color = ROLE_COLORS.get(role, 0)
track = TrackDef(
name=role.capitalize(),
volume=0.85 * vol_mult,
pan=0.0,
color=0,
color=role_color,
clips=section_clips,
plugins=plugins,
send_reverb=send_reverb,
@@ -440,6 +496,17 @@ def main() -> None:
with open(genre_path, "r", encoding="utf-8") as f:
genre_config = json.load(f)
# Randomize chord progression selection from 5 options in reggaeton_2009.json
progressions = genre_config.get("chord_progressions", [])
if progressions:
# Weighted random selection by popularity
prog_names = [p.get("name", "") for p in progressions]
pop_values = [p.get("popularity", 0.5) for p in progressions]
selected_prog = random.choices(prog_names, weights=pop_values, k=1)[0]
progression_name = selected_prog
else:
progression_name = "i-VII-VI-VII"
# Load sample index
index_path = _ROOT / "data" / "sample_index.json"
if not index_path.exists():
@@ -475,8 +542,29 @@ def main() -> None:
meta=meta,
tracks=tracks + return_tracks,
sections=sections,
progression_name=progression_name,
)
# Wire sends: find return track indices and set send_level on non-return tracks
ret_idx_map: dict[str, int] = {}
for idx, track in enumerate(song.tracks):
if track.name == "Reverb":
ret_idx_map["reverb"] = idx
elif track.name == "Delay":
ret_idx_map["delay"] = idx
reverb_idx = ret_idx_map.get("reverb", 0)
delay_idx = ret_idx_map.get("delay", 1)
for track in song.tracks:
if track.name not in ("Reverb", "Delay", "master"):
track.send_level = {
reverb_idx: 0.15, # send to reverb
delay_idx: 0.10, # send to delay
}
# Wire master chain
song.master_plugins = ["Pro-Q_3", "Pro-C_2", "Pro-L_2"]
# Validate
errors = song.validate()
if errors:

View File

@@ -349,6 +349,20 @@ def clap_24_notes(
return _apply_groove({CH_CL: notes}, groove_strength)
def generate_clap_pattern(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
groove_strength: float = 0.0,
) -> dict[int, list[dict]]:
"""Generate clap pattern using CLAP_DEMBOW.
Snare on beats 2, 3.5 — standard reggaeton clap.
Returns {CH_CL: [notes...]}.
"""
return clap_24_notes(bars, velocity_mult, density, groove_strength)
# ---------------------------------------------------------------------------
# Percussion generators
# ---------------------------------------------------------------------------

View File

@@ -111,6 +111,9 @@ class ClipDef:
audio_path: Absolute path to audio file (for audio clips)
midi_notes: List of MIDI notes (for MIDI clips)
name: Display name
loop: Whether the audio clip loops (default False)
fade_in: Fade-in duration in seconds (default 0.0)
fade_out: Fade-out duration in seconds (default 0.0)
"""
position: float
@@ -118,6 +121,9 @@ class ClipDef:
name: str = ""
audio_path: str | None = None # for audio clips
midi_notes: list[MidiNote] = field(default_factory=list) # for MIDI clips
loop: bool = False
fade_in: float = 0.0
fade_out: float = 0.0
@property
def is_midi(self) -> bool:
@@ -159,6 +165,7 @@ class TrackDef:
plugins: VST plugins on this track
send_reverb: Reverb send level 0.01.0
send_delay: Delay send level 0.01.0
send_level: Dict mapping return track index → send level 0.01.0
"""
name: str
@@ -169,6 +176,7 @@ class TrackDef:
plugins: list[PluginDef] = field(default_factory=list)
send_reverb: float = 0.0
send_delay: float = 0.0
send_level: dict[int, float] = field(default_factory=dict)
@dataclass
@@ -205,6 +213,7 @@ class SongDefinition:
section_template: Section template name (default "standard")
samples: Sample file map (name → filename)
sections: Section definitions in playback order
master_plugins: List of plugin registry keys for master FX chain
"""
meta: SongMeta
@@ -215,6 +224,7 @@ class SongDefinition:
section_template: str = "standard"
samples: dict[str, str] = field(default_factory=dict)
sections: list[SectionDef] = field(default_factory=list)
master_plugins: list[str] = field(default_factory=list)
# -------------------------------------------------------------------------
# Validation

View File

@@ -1642,17 +1642,21 @@ class RPPBuilder:
defaults_copy[1] = f"{{{master_guid}}}"
master.append(defaults_copy)
# Master track FXCHAIN (MASTER_FX 1 requires FXCHAIN)
master_fxchain = Element("FXCHAIN", [])
for line in _FXCHAIN_HEADER:
master_fxchain.append([v for v in line])
for line in _FXCHAIN_FOOTER:
if line:
footer_copy = [v for v in line]
if footer_copy[0] == "FXID":
footer_copy[1] = f"{{{self._make_seeded_guid()}}}"
master_fxchain.append(footer_copy)
master.append(master_fxchain)
# Master track FXCHAIN — use _build_master_fxchain() if plugins are defined
if self.song.master_plugins:
master.append(self._build_master_fxchain())
else:
# Empty FXCHAIN skeleton (MASTER_FX 1 requires FXCHAIN element)
master_fxchain = Element("FXCHAIN", [])
for line in _FXCHAIN_HEADER:
master_fxchain.append([v for v in line])
for line in _FXCHAIN_FOOTER:
if line:
footer_copy = [v for v in line]
if footer_copy[0] == "FXID":
footer_copy[1] = f"{{{self._make_seeded_guid()}}}"
master_fxchain.append(footer_copy)
master.append(master_fxchain)
root.append(master)
# User tracks
@@ -1661,6 +1665,28 @@ class RPPBuilder:
return root
def _build_master_fxchain(self) -> Element:
"""Build the FXCHAIN Element for the master track with master_plugins.
Uses _build_plugin() for each plugin in SongDefinition.master_plugins.
"""
fxchain = Element("FXCHAIN", [])
for line in _FXCHAIN_HEADER:
fxchain.append([v for v in line])
for idx, plugin_name in enumerate(self.song.master_plugins):
plugin = PluginDef(name=plugin_name, path="", index=idx)
fxchain.append(self._build_plugin(plugin))
fxid_guid = self._make_seeded_guid()
for line in _FXCHAIN_FOOTER:
if line:
footer_copy = [v for v in line]
if footer_copy[0] == "FXID":
footer_copy[1] = f"{{{fxid_guid}}}"
fxchain.append(footer_copy)
return fxchain
def _build_track(self, track: TrackDef) -> Element:
"""Build a TRACK Element with all default attributes from test_vst3.rpp."""
track_guid = self._make_seeded_guid()
@@ -1681,6 +1707,10 @@ class RPPBuilder:
defaults_copy = ["SEL", "1"]
track_elem.append(defaults_copy)
# Track color
if track.color > 0:
track_elem.append(["COLOR", str(track.color)])
# Plugins (FXCHAIN) — wrap VST elements inside proper FXCHAIN structure
if track.plugins:
fxchain = Element("FXCHAIN", [])
@@ -1694,12 +1724,17 @@ class RPPBuilder:
fxchain.append(["FXID", f"{{{fxid_guid}}}"])
track_elem.append(fxchain)
# Send effects
# Legacy send effects (send_reverb, send_delay)
if track.send_reverb > 0:
track_elem.append(["AUXRECV", "0", f"{track.send_reverb:.6f}", "-1", "-1", "0"])
if track.send_delay > 0:
track_elem.append(["AUXRECV", "1", f"{track.send_delay:.6f}", "-1", "-1", "0"])
# Generalised send_level dict — maps return track index → send level
for ret_idx, level in track.send_level.items():
if level > 0:
track_elem.append(["AUXRECV", str(ret_idx), f"{level:.6f}", "-1", "-1", "0"])
# Clips (items)
for clip in track.clips:
track_elem.append(self._build_clip(clip))
@@ -1759,6 +1794,12 @@ class RPPBuilder:
item.append(["LENGTH", str(clip.length)])
if clip.name:
item.append(["NAME", clip.name])
if clip.loop:
item.append(["LOOP", "1"])
if clip.fade_in > 0:
item.append(["FADEIN", f"{clip.fade_in:.6f}"])
if clip.fade_out > 0:
item.append(["FADEOUT", f"{clip.fade_out:.6f}"])
if clip.is_audio and clip.audio_path:
source = Element("SOURCE", ["WAVE"])