feat: professional reggaeton production engine — 7 SDD changes, 302 tests
- section-energy: track activity matrix + volume/velocity multipliers per section - smart-chords: ChordEngine with voice leading, inversions, 4 emotion modes - hook-melody: melody engine with hook/stabs/smooth styles, call-and-response - mix-calibration: Calibrator module (LUFS volumes, HPF/LPF, stereo, sends, master) - transitions-fx: FX track with risers/impacts/sweeps at section boundaries - sidechain: MIDI CC11 bass ducking on kick hits via DrumLoopAnalyzer - presets-pack: role-aware plugin presets (Serum/Decapitator/Omnisphere per role) Full SDD pipeline (propose→spec→design→tasks→apply→verify) for all 7 changes. 302/302 tests passing.
This commit is contained in:
49
scripts/_match_samples.py
Normal file
49
scripts/_match_samples.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json, hashlib, os
|
||||
|
||||
# Load our sample index
|
||||
idx = json.load(open('data/sample_index.json'))
|
||||
samples = idx['samples']
|
||||
|
||||
# Build MD5 → sample map
|
||||
md5_map = {}
|
||||
for s in samples:
|
||||
h = s.get('file_hash', '')
|
||||
if h:
|
||||
md5_map[h] = s
|
||||
|
||||
# Ableton drumloop paths
|
||||
ableton_dir = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\libreria\reggaeton\drumloops"
|
||||
ableton_samples = [
|
||||
"100bpm filtrado drumloop.wav",
|
||||
"90bpm reggaeton antiguo drumloop.wav",
|
||||
"94bpm reggaeton antiguo 2 drumloop.wav",
|
||||
"100bpm_gata-only_drumloop.wav",
|
||||
"98bpm yera drumloop.wav",
|
||||
"98bpm nachogflow drumloop.wav",
|
||||
"90bpm reggaeton antiguo 3 drumloop.wav",
|
||||
]
|
||||
|
||||
print("Matching Ableton samples to our library:\n")
|
||||
for name in ableton_samples:
|
||||
path = os.path.join(ableton_dir, name)
|
||||
if not os.path.exists(path):
|
||||
print(f" NOT FOUND: {name}")
|
||||
continue
|
||||
|
||||
# Compute MD5
|
||||
h = hashlib.md5()
|
||||
with open(path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(8192), b''):
|
||||
h.update(chunk)
|
||||
md5 = h.hexdigest()
|
||||
|
||||
# Lookup in index
|
||||
match = md5_map.get(md5)
|
||||
if match:
|
||||
print(f" {name}")
|
||||
print(f" -> {match['original_name']}")
|
||||
print(f" MD5: {md5}")
|
||||
print(f" Path: {match['original_path']}")
|
||||
else:
|
||||
print(f" {name} -> NO MATCH (md5={md5})")
|
||||
print()
|
||||
@@ -23,11 +23,14 @@ _ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from src.core.schema import (
|
||||
SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote,
|
||||
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
|
||||
@@ -46,15 +49,15 @@ ABLETON_DRUMLOOP_DIR = Path(
|
||||
# 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
|
||||
"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
|
||||
@@ -100,7 +103,7 @@ BASS_PATTERN_8BARS = [
|
||||
SECTIONS = [
|
||||
("intro", 4, 0.3, False),
|
||||
("verse", 8, 0.5, True),
|
||||
("build", 4, 0.7, False),
|
||||
("pre-chorus", 4, 0.7, False),
|
||||
("chorus", 8, 1.0, True),
|
||||
("verse2", 8, 0.5, True),
|
||||
("chorus2", 8, 1.0, True),
|
||||
@@ -132,12 +135,13 @@ FX_CHAINS = {
|
||||
}
|
||||
|
||||
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),
|
||||
"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 = {
|
||||
@@ -152,6 +156,56 @@ VOLUME_LEVELS = {
|
||||
|
||||
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
|
||||
@@ -180,16 +234,24 @@ def get_pentatonic(root: str, is_minor: bool, octave: int) -> list[int]:
|
||||
return [root_midi + i for i in intervals]
|
||||
|
||||
|
||||
def make_plugin(registry_key: str, index: int) -> PluginDef:
|
||||
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)
|
||||
return PluginDef(name=registry_key, path=path, index=index, preset_data=preset)
|
||||
return PluginDef(name=registry_key, path=registry_key, index=index)
|
||||
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 = [SectionDef(name=n, bars=b, energy=e) for n, b, e, _ in SECTIONS]
|
||||
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:
|
||||
@@ -198,6 +260,45 @@ def build_section_structure():
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -220,9 +321,12 @@ def build_drumloop_track(sections, offsets, seed: int = 0) -> TrackDef:
|
||||
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
|
||||
section_key = section.name
|
||||
variant = DRUMLOOP_ASSIGNMENTS.get(section_key, "seco")
|
||||
variant = DRUMLOOP_ASSIGNMENTS.get(section.name, "seco")
|
||||
|
||||
if variant == "empty":
|
||||
continue # no drumloop in this section
|
||||
@@ -241,9 +345,10 @@ def build_drumloop_track(sections, offsets, seed: int = 0) -> TrackDef:
|
||||
name=f"{section.name.capitalize()} Drumloop",
|
||||
audio_path=path,
|
||||
loop=True,
|
||||
vol_mult=section.vol_mult,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("drumloop", []))]
|
||||
plugins = [make_plugin(fx, i, role="drumloop") for i, fx in enumerate(FX_CHAINS.get("drumloop", []))]
|
||||
return TrackDef(
|
||||
name="Drumloop",
|
||||
volume=VOLUME_LEVELS["drumloop"],
|
||||
@@ -272,8 +377,8 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef:
|
||||
|
||||
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"):
|
||||
# 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)]
|
||||
@@ -286,9 +391,10 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef:
|
||||
name=f"{section.name.capitalize()} Perc",
|
||||
audio_path=str(perc_path),
|
||||
loop=True,
|
||||
vol_mult=section.vol_mult,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("perc", []))]
|
||||
plugins = [make_plugin(fx, i, role="perc") for i, fx in enumerate(FX_CHAINS.get("perc", []))]
|
||||
return TrackDef(
|
||||
name="Perc",
|
||||
volume=VOLUME_LEVELS["perc"],
|
||||
@@ -298,18 +404,25 @@ def build_perc_track(sections, offsets, seed: int = 0) -> TrackDef:
|
||||
)
|
||||
|
||||
|
||||
def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> TrackDef:
|
||||
"""808 bass using PROVEN harmonic pattern from Ableton project."""
|
||||
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):
|
||||
vm = section.energy
|
||||
velocity = int(80 + 15 * vm) # 80-95 depending on energy
|
||||
if not _section_active(section.name, "bass", TRACK_ACTIVITY):
|
||||
continue
|
||||
|
||||
velocity = int(80 * section.velocity_mult)
|
||||
|
||||
notes = []
|
||||
bars = section.bars
|
||||
@@ -329,15 +442,36 @@ def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> Track
|
||||
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) for i, fx in enumerate(FX_CHAINS.get("bass", []))]
|
||||
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"],
|
||||
@@ -347,20 +481,30 @@ def build_bass_track(sections, offsets, key_root: str, key_minor: bool) -> Track
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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):
|
||||
ci = bar % len(CHORD_PROGRESSION)
|
||||
interval, quality = CHORD_PROGRESSION[ci]
|
||||
for pitch in build_chord(root_midi + interval, quality):
|
||||
chord_idx = bar % len(voicings)
|
||||
voicing = voicings[chord_idx]
|
||||
for pitch in voicing:
|
||||
notes.append(MidiNote(
|
||||
pitch=pitch,
|
||||
start=bar * 4.0,
|
||||
@@ -375,7 +519,7 @@ def build_chords_track(sections, offsets, key_root: str, key_minor: bool) -> Tra
|
||||
midi_notes=notes,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("chords", []))]
|
||||
plugins = [make_plugin(fx, i, role="chords") for i, fx in enumerate(FX_CHAINS.get("chords", []))]
|
||||
return TrackDef(
|
||||
name="Chords",
|
||||
volume=VOLUME_LEVELS["chords"],
|
||||
@@ -386,33 +530,26 @@ def build_chords_track(sections, offsets, key_root: str, key_minor: bool) -> Tra
|
||||
|
||||
|
||||
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)
|
||||
"""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 chorus and final sections
|
||||
if section.name not in ("chorus", "chorus2", "final"):
|
||||
# Lead only in sections where the lead role is active
|
||||
if not _section_active(section.name, "lead", TRACK_ACTIVITY):
|
||||
continue
|
||||
|
||||
vm = section.energy
|
||||
density = 0.4
|
||||
notes = []
|
||||
# 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)
|
||||
|
||||
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),
|
||||
))
|
||||
# Scale velocities to section energy
|
||||
for note in notes:
|
||||
note.velocity = int(note.velocity * section.velocity_mult)
|
||||
|
||||
if notes:
|
||||
clips.append(ClipDef(
|
||||
@@ -420,9 +557,10 @@ def build_lead_track(sections, offsets, key_root: str, key_minor: bool, seed: in
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Lead",
|
||||
midi_notes=notes,
|
||||
vol_mult=section.vol_mult,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("lead", []))]
|
||||
plugins = [make_plugin(fx, i, role="lead") for i, fx in enumerate(FX_CHAINS.get("lead", []))]
|
||||
return TrackDef(
|
||||
name="Lead",
|
||||
volume=VOLUME_LEVELS["lead"],
|
||||
@@ -440,7 +578,7 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef:
|
||||
clips = []
|
||||
if clap_path:
|
||||
for section, sec_off in zip(sections, offsets):
|
||||
if not section.name.startswith(("chorus", "verse", "final")):
|
||||
if not _section_active(section.name, "clap", TRACK_ACTIVITY):
|
||||
continue
|
||||
for bar in range(section.bars):
|
||||
for cb in CLAP_POSITIONS:
|
||||
@@ -449,9 +587,10 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef:
|
||||
length=0.5,
|
||||
name=f"{section.name.capitalize()} Clap",
|
||||
audio_path=clap_path,
|
||||
vol_mult=section.vol_mult,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("clap", []))]
|
||||
plugins = [make_plugin(fx, i, role="clap") for i, fx in enumerate(FX_CHAINS.get("clap", []))]
|
||||
return TrackDef(
|
||||
name="Clap",
|
||||
volume=VOLUME_LEVELS["clap"],
|
||||
@@ -461,6 +600,53 @@ def build_clap_track(selector: SampleSelector, sections, offsets) -> TrackDef:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -469,13 +655,13 @@ def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackD
|
||||
|
||||
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"):
|
||||
# Pad only where the pad role is active
|
||||
if not _section_active(section.name, "pad", TRACK_ACTIVITY):
|
||||
continue
|
||||
|
||||
vm = section.energy
|
||||
velocity = int(55 * section.velocity_mult)
|
||||
notes = [
|
||||
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(55 * vm))
|
||||
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=velocity)
|
||||
for p in chord
|
||||
]
|
||||
clips.append(ClipDef(
|
||||
@@ -483,9 +669,10 @@ def build_pad_track(sections, offsets, key_root: str, key_minor: bool) -> TrackD
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Pad",
|
||||
midi_notes=notes,
|
||||
vol_mult=section.vol_mult,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("pad", []))]
|
||||
plugins = [make_plugin(fx, i, role="pad") for i, fx in enumerate(FX_CHAINS.get("pad", []))]
|
||||
return TrackDef(
|
||||
name="Pad",
|
||||
volume=VOLUME_LEVELS["pad"],
|
||||
@@ -526,6 +713,20 @@ def main() -> None:
|
||||
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:
|
||||
@@ -552,14 +753,22 @@ def main() -> None:
|
||||
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),
|
||||
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),
|
||||
]
|
||||
|
||||
@@ -577,7 +786,10 @@ def main() -> None:
|
||||
track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]}
|
||||
|
||||
# Assemble
|
||||
meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Instrumental")
|
||||
meta = SongMeta(
|
||||
bpm=bpm, key=key, title="Reggaeton Instrumental",
|
||||
calibrate=not args.no_calibrate,
|
||||
)
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=all_tracks,
|
||||
@@ -585,6 +797,10 @@ def main() -> None:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user