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:
17990
output/full_reggaeton_song.rpp
Normal file
17990
output/full_reggaeton_song.rpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.0–1.0
|
||||
send_delay: Delay send level 0.0–1.0
|
||||
send_level: Dict mapping return track index → send level 0.0–1.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
|
||||
|
||||
@@ -1642,7 +1642,11 @@ class RPPBuilder:
|
||||
defaults_copy[1] = f"{{{master_guid}}}"
|
||||
master.append(defaults_copy)
|
||||
|
||||
# Master track FXCHAIN (MASTER_FX 1 requires 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])
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user