feat: drumloop-first v2 — scored selection, WAV preference, no vocals
- Score drumloops by key_confidence + onset_density + duration + balance + format - Prefer WAV over MP3 (lossless > lossy) - 6 tracks only: Drumloop, Bass, Chords, Lead, Clap, Pad - Clap ONLY in chorus+verse (dembow on beats 2, 3.5) - Bass tresillo filtered by kick-free zones - Chords i-VI-III-VII on downbeats - Lead pentatonic, avoids transients, chord tones on strong beats - Master chain: Pro-Q 3 → Pro-C 2 → Pro-L 2 - 90 tests passing, 18 plugins, clean RPP output
This commit is contained in:
2019
output/drumloop_v2.rpp
Normal file
2019
output/drumloop_v2.rpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,153 +0,0 @@
|
||||
"""Analyze drumloops from the library and output structured forensic data.
|
||||
|
||||
Usage:
|
||||
python scripts/analyze_drumloop.py [--count N] [--output PATH] [--json PATH]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT))
|
||||
|
||||
from src.composer.drum_analyzer import DrumLoopAnalyzer
|
||||
|
||||
|
||||
def load_drumloop_paths(index_path: Path, count: int = 5) -> list[dict]:
|
||||
with open(index_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
drumloops = [s for s in data["samples"] if s.get("role") == "drumloop"]
|
||||
|
||||
scored = []
|
||||
for d in drumloops:
|
||||
path = Path(d["original_path"])
|
||||
if not path.exists():
|
||||
continue
|
||||
dur = d.get("signal", {}).get("duration", 0)
|
||||
onsets = d.get("perceptual", {}).get("onset_count", 0)
|
||||
density = onsets / max(dur, 0.01)
|
||||
tempo = d.get("perceptual", {}).get("tempo", 0)
|
||||
if 85 <= tempo <= 150 and 6 <= dur <= 35:
|
||||
scored.append((d, density))
|
||||
|
||||
scored.sort(key=lambda x: abs(x[1] - 4.0))
|
||||
return [s[0] for s in scored[:count]]
|
||||
|
||||
|
||||
def print_analysis(result_dict: dict) -> None:
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {Path(result_dict['file_path']).name}")
|
||||
print(f"{'='*70}")
|
||||
print(f" BPM: {result_dict['bpm']}")
|
||||
print(f" Duration: {result_dict['duration']:.2f}s")
|
||||
print(f" Bars: {result_dict['bar_count']}")
|
||||
print(f" Key: {result_dict['key']} (conf: {result_dict['key_confidence']:.2f})")
|
||||
print(f" Beat grid: {len(result_dict['beat_grid']['quarter'])} quarters, "
|
||||
f"{len(result_dict['beat_grid']['eighth'])} eighths, "
|
||||
f"{len(result_dict['beat_grid']['sixteenth'])} sixteenths")
|
||||
|
||||
summary = result_dict["summary"]
|
||||
total = summary["kick_count"] + summary["snare_count"] + summary["hihat_count"] + summary["other_count"]
|
||||
print(f"\n Transients: {total} total")
|
||||
print(f" Kicks: {summary['kick_count']}")
|
||||
print(f" Snares: {summary['snare_count']}")
|
||||
print(f" HiHats: {summary['hihat_count']}")
|
||||
print(f" Other: {summary['other_count']}")
|
||||
|
||||
transients_by_type = {}
|
||||
for t in result_dict["transients"]:
|
||||
transients_by_type.setdefault(t["type"], []).append(t)
|
||||
|
||||
for ttype in ["kick", "snare", "hihat", "other"]:
|
||||
ts = transients_by_type.get(ttype, [])
|
||||
if not ts:
|
||||
continue
|
||||
print(f"\n {ttype.upper()} positions (beat positions):")
|
||||
positions = [f"{t['beat_pos']:.2f}" for t in ts[:20]]
|
||||
line = " " + " ".join(positions)
|
||||
if len(ts) > 20:
|
||||
line += f" ... +{len(ts)-20} more"
|
||||
print(line)
|
||||
|
||||
if result_dict["energy_profile"]:
|
||||
print(f"\n Energy profile (first 16 beats):")
|
||||
bars_e = result_dict["energy_profile"][:16]
|
||||
max_e = max(bars_e) if bars_e else 1
|
||||
for i, e in enumerate(bars_e):
|
||||
bar = i // 4
|
||||
beat = i % 4
|
||||
filled = int((e / max_e) * 30) if max_e > 0 else 0
|
||||
print(f" Bar {bar+1} Beat {beat+1}: {'|' * filled} ({e:.4f})")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Analyze drumloops forensically")
|
||||
parser.add_argument("--count", type=int, default=3, help="Number of drumloops to analyze")
|
||||
parser.add_argument("--index", type=str, default=None, help="Path to sample_index.json")
|
||||
parser.add_argument("--file", type=str, default=None, help="Analyze a single file instead")
|
||||
parser.add_argument("--json", type=str, default=None, help="Save results as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
index_path = Path(args.index) if args.index else PROJECT / "data" / "sample_index.json"
|
||||
results = []
|
||||
|
||||
if args.file:
|
||||
print(f"Analyzing: {args.file}")
|
||||
analyzer = DrumLoopAnalyzer(args.file)
|
||||
result = analyzer.analyze()
|
||||
results.append(result.to_dict())
|
||||
print_analysis(results[0])
|
||||
else:
|
||||
drumloops = load_drumloop_paths(index_path, args.count)
|
||||
if not drumloops:
|
||||
print("No suitable drumloops found.")
|
||||
return
|
||||
|
||||
print(f"Selected {len(drumloops)} drumloops for analysis:\n")
|
||||
for d in drumloops:
|
||||
print(f" - {d['original_name']} "
|
||||
f"(tempo={d.get('perceptual',{}).get('tempo','?')}, "
|
||||
f"dur={d.get('signal',{}).get('duration',0):.1f}s)")
|
||||
|
||||
for d in drumloops:
|
||||
path = d["original_path"]
|
||||
print(f"\nAnalyzing: {Path(path).name}...")
|
||||
analyzer = DrumLoopAnalyzer(path)
|
||||
result = analyzer.analyze()
|
||||
results.append(result.to_dict())
|
||||
print_analysis(result.to_dict())
|
||||
|
||||
if args.json:
|
||||
out_path = Path(args.json)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||
print(f"\nResults saved to: {out_path}")
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("DRUMLOOP-FIRST GENERATION APPROACH")
|
||||
print(f"{'='*70}")
|
||||
print("""
|
||||
1. SELECT drumloop -> extract BPM + beat grid + transient map
|
||||
2. ALIGN project -> set REAPER tempo to drumloop BPM
|
||||
3. GENERATE bass -> tresillo pattern in kick-free zones
|
||||
- Reggaeton tresillo: notes at 0.0, 0.75, 1.5, 2.0, 2.75, 3.5
|
||||
- Place bass between kick transients (margin +/-0.15 beats)
|
||||
4. GENERATE chords -> change on downbeats (beat 1 of each bar)
|
||||
- Sustain through bar, use i-VI-III-VII progression
|
||||
- Match key from drumloop analysis
|
||||
5. GENERATE melody -> place on transient-free zones
|
||||
- Emphasize chord tones on strong beats
|
||||
- Syncopation matches dembow feel
|
||||
6. GENERATE vocals -> chops in gaps between drum transients
|
||||
7. SELECT samples -> match drumloop key for compatible tonal samples
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
"""Drumloop-first REAPER .rpp project generator for reggaeton.
|
||||
"""Drumloop-first REAPER .rpp project generator for reggaeton instrumental.
|
||||
|
||||
The drumloop drives EVERYTHING: BPM, key, rhythm all come from analysis.
|
||||
Bass, chords, melody, and vocals are built to sync with the drumloop's rhythm.
|
||||
Bass, chords, lead, and pad are built to sync with the drumloop's rhythm.
|
||||
NO vocals — this is an instrumental-only generator.
|
||||
|
||||
Usage:
|
||||
python scripts/compose.py --output output/song.rpp
|
||||
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
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,60 +41,147 @@ ROLE_COLORS = {
|
||||
"clap": 4,
|
||||
"bass": 5,
|
||||
"chords": 9,
|
||||
"melody": 11,
|
||||
"lead": 11,
|
||||
"pad": 13,
|
||||
"vocal": 15,
|
||||
}
|
||||
|
||||
# Section structure: (name, bars, energy, has_clap)
|
||||
# Clap ONLY on chorus and verse sections
|
||||
SECTIONS = [
|
||||
("intro", 4, 0.4),
|
||||
("verse", 8, 0.6),
|
||||
("build", 4, 0.7),
|
||||
("chorus", 8, 1.0),
|
||||
("break", 4, 0.5),
|
||||
("chorus", 8, 1.0),
|
||||
("outro", 4, 0.3),
|
||||
("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),
|
||||
]
|
||||
|
||||
# Tresillo rhythm positions in beats (within a bar)
|
||||
TRESILLO_POSITIONS = [0.0, 0.75, 1.5, 2.0, 2.75, 3.5]
|
||||
|
||||
# Clap positions in beats (within a bar)
|
||||
CLAP_POSITIONS = [1.0, 3.5]
|
||||
|
||||
# i-VI-III-VII chord progression in semitones from root (minor key)
|
||||
CHORD_PROGRESSION = [
|
||||
(0, "minor"),
|
||||
(8, "major"),
|
||||
(3, "major"),
|
||||
(10, "major"),
|
||||
(0, "minor"), # i
|
||||
(8, "major"), # VI
|
||||
(3, "major"), # III
|
||||
(10, "major"), # VII
|
||||
]
|
||||
|
||||
# FX chains per track role (before return sends)
|
||||
FX_CHAINS = {
|
||||
"drumloop": ["Decapitator", "Radiator"],
|
||||
"bass": ["Decapitator", "Gullfoss_Master"],
|
||||
"chords": ["PhaseMistress", "EchoBoy"],
|
||||
"melody": ["Tremolator"],
|
||||
"vocal": ["VC_76", "Radiator", "EchoBoy"],
|
||||
"pad": ["ValhallaDelay"],
|
||||
"bass": ["Serum_2", "Decapitator", "Gullfoss_Master"],
|
||||
"chords": ["Omnisphere", "PhaseMistress", "EchoBoy"],
|
||||
"lead": ["Serum_2", "Tremolator"],
|
||||
"clap": ["Decapitator"],
|
||||
"pad": ["Omnisphere", "ValhallaDelay"],
|
||||
}
|
||||
|
||||
# Send levels (reverb, delay) per track role
|
||||
SEND_LEVELS = {
|
||||
"bass": (0.05, 0.02),
|
||||
"bass": (0.05, 0.02),
|
||||
"chords": (0.15, 0.08),
|
||||
"melody": (0.10, 0.05),
|
||||
"vocal": (0.20, 0.10),
|
||||
"pad": (0.25, 0.15),
|
||||
"lead": (0.10, 0.05),
|
||||
"clap": (0.05, 0.02),
|
||||
"pad": (0.25, 0.15),
|
||||
}
|
||||
|
||||
# Track volume levels
|
||||
VOLUME_LEVELS = {
|
||||
"bass": 0.82,
|
||||
"drumloop": 0.85,
|
||||
"chords": 0.70,
|
||||
"melody": 0.75,
|
||||
"vocal": 0.80,
|
||||
"pad": 0.65,
|
||||
"clap": 0.80,
|
||||
"bass": 0.82,
|
||||
"chords": 0.70,
|
||||
"lead": 0.75,
|
||||
"clap": 0.80,
|
||||
"pad": 0.65,
|
||||
}
|
||||
|
||||
# Backward compat stubs for test imports
|
||||
EFFECT_ALIASES: dict[str, str] = {}
|
||||
# Master volume
|
||||
MASTER_VOLUME = 0.85
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: Infrastructure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def score_drumloop(sample: dict, analysis) -> float:
|
||||
"""Score a drumloop candidate for selection quality.
|
||||
|
||||
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
|
||||
|
||||
Returns:
|
||||
Composite score 0.0–1.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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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 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
|
||||
for sec in sections:
|
||||
offsets.append(off)
|
||||
off += sec.bars
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,25 +189,24 @@ EFFECT_ALIASES: dict[str, str] = {}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 root_to_midi(root: str, octave: int) -> int:
|
||||
return NOTE_TO_MIDI[root] + (octave + 1) * 12
|
||||
|
||||
|
||||
def get_pentatonic(root: str, is_minor: bool, octave: int) -> list[int]:
|
||||
root_midi = root_to_midi(root, octave)
|
||||
"""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]
|
||||
intervals = [0, 3, 5, 7, 10] # minor pentatonic
|
||||
else:
|
||||
intervals = [0, 2, 4, 7, 9]
|
||||
intervals = [0, 2, 4, 7, 9] # major pentatonic
|
||||
return [root_midi + i for i in intervals]
|
||||
|
||||
|
||||
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]
|
||||
@@ -129,6 +217,7 @@ def build_chord(root_midi: int, quality: str) -> list[int]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
@@ -137,30 +226,46 @@ def make_plugin(registry_key: str, index: int) -> PluginDef:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Track builders
|
||||
# Phase 2: Track Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_drumloop_track(drumloop_path: str, total_beats: float) -> TrackDef:
|
||||
clips = [ClipDef(
|
||||
position=0.0, length=total_beats, name="Drumloop Full",
|
||||
audio_path=drumloop_path, loop=True,
|
||||
)]
|
||||
"""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,
|
||||
name="Drumloop",
|
||||
volume=VOLUME_LEVELS["drumloop"],
|
||||
pan=0.0,
|
||||
color=ROLE_COLORS["drumloop"],
|
||||
clips=clips,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
def build_bass_track(
|
||||
analysis, sections, offsets, key_root, key_minor,
|
||||
analysis,
|
||||
sections: list[SectionDef],
|
||||
offsets: list[float],
|
||||
key_root: str,
|
||||
key_minor: bool,
|
||||
) -> TrackDef:
|
||||
root_midi = root_to_midi(key_root, 2)
|
||||
"""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 in_kfz(beat: float) -> bool:
|
||||
s = beat * beat_dur
|
||||
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 = []
|
||||
@@ -171,28 +276,41 @@ def build_bass_track(
|
||||
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)
|
||||
notes.append(MidiNote(
|
||||
pitch=root_midi, start=bar * 4.0 + pos,
|
||||
duration=0.5, velocity=int(100 * vm),
|
||||
pitch=root_midi,
|
||||
start=bar * 4.0 + pos,
|
||||
duration=0.5,
|
||||
velocity=int(100 * vm),
|
||||
))
|
||||
if notes:
|
||||
clips.append(ClipDef(
|
||||
position=sec_off * 4.0, length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Bass", midi_notes=notes,
|
||||
position=sec_off * 4.0,
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Bass",
|
||||
midi_notes=notes,
|
||||
))
|
||||
|
||||
plugins = [make_plugin("Serum_2", 0)]
|
||||
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("bass", []))]
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("bass", []))]
|
||||
return TrackDef(
|
||||
name="Bass", volume=VOLUME_LEVELS["bass"], pan=0.0,
|
||||
color=ROLE_COLORS["bass"], clips=clips, plugins=plugins,
|
||||
name="Bass",
|
||||
volume=VOLUME_LEVELS["bass"],
|
||||
pan=0.0,
|
||||
color=ROLE_COLORS["bass"],
|
||||
clips=clips,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
def build_chords_track(
|
||||
analysis, sections, offsets, key_root, key_minor,
|
||||
analysis,
|
||||
sections: list[SectionDef],
|
||||
offsets: list[float],
|
||||
key_root: str,
|
||||
key_minor: bool,
|
||||
) -> TrackDef:
|
||||
root_midi = root_to_midi(key_root, 3)
|
||||
"""Build the chords track — i-VI-III-VII on downbeats, one clip per section."""
|
||||
root_midi = key_to_midi_root(key_root, 3)
|
||||
clips = []
|
||||
for section, sec_off in zip(sections, offsets):
|
||||
vm = section.energy
|
||||
@@ -202,186 +320,322 @@ def build_chords_track(
|
||||
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,
|
||||
pitch=pitch,
|
||||
start=bar * 4.0,
|
||||
duration=4.0,
|
||||
velocity=int(80 * 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,
|
||||
position=sec_off * 4.0,
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Chords",
|
||||
midi_notes=notes,
|
||||
))
|
||||
|
||||
plugins = [make_plugin("Omnisphere", 0)]
|
||||
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("chords", []))]
|
||||
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,
|
||||
color=ROLE_COLORS["chords"], clips=clips, plugins=plugins,
|
||||
name="Chords",
|
||||
volume=VOLUME_LEVELS["chords"],
|
||||
pan=0.0,
|
||||
color=ROLE_COLORS["chords"],
|
||||
clips=clips,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
def build_melody_track(
|
||||
analysis, sections, offsets, key_root, key_minor, seed=42,
|
||||
def build_lead_track(
|
||||
analysis,
|
||||
sections: list[SectionDef],
|
||||
offsets: list[float],
|
||||
key_root: str,
|
||||
key_minor: bool,
|
||||
seed: int = 42,
|
||||
) -> TrackDef:
|
||||
penta = get_pentatonic(key_root, key_minor, 4) + get_pentatonic(key_root, key_minor, 5)
|
||||
"""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: float = 0.2) -> bool:
|
||||
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 * beat_dur for tt in transient_times)
|
||||
return any(abs(s - tt) < margin_beats * beat_dur for tt in transient_times)
|
||||
|
||||
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)
|
||||
|
||||
notes = []
|
||||
density = {"chorus": 0.6, "verse": 0.35, "build": 0.35}.get(section.name, 0.2)
|
||||
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(sec_off * 4.0 + bp):
|
||||
if near_transient(abs_bp, margin_beats=0.2):
|
||||
continue
|
||||
strong = sixteenth in (0, 8)
|
||||
|
||||
strong = sixteenth in (0, 8) # beat 0 and beat 2 of bar
|
||||
# On strong beats: emphasize chord tones (1st, 3rd, 5th of pentatonic)
|
||||
pool = [penta[0], penta[2], penta[4]] if strong else penta
|
||||
notes.append(MidiNote(
|
||||
pitch=rng.choice(pool), start=bp,
|
||||
pitch=rng.choice(pool),
|
||||
start=bp,
|
||||
duration=0.5 if strong else 0.25,
|
||||
velocity=int((90 if strong else 70) * vm),
|
||||
))
|
||||
|
||||
if notes:
|
||||
clips.append(ClipDef(
|
||||
position=sec_off * 4.0, length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Melody", midi_notes=notes,
|
||||
position=sec_off * 4.0,
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Lead",
|
||||
midi_notes=notes,
|
||||
))
|
||||
|
||||
plugins = [make_plugin("Serum_2", 0)]
|
||||
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("melody", []))]
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("lead", []))]
|
||||
return TrackDef(
|
||||
name="Melody", volume=VOLUME_LEVELS["melody"], pan=0.0,
|
||||
color=ROLE_COLORS["melody"], clips=clips, plugins=plugins,
|
||||
name="Lead",
|
||||
volume=VOLUME_LEVELS["lead"],
|
||||
pan=0.0,
|
||||
color=ROLE_COLORS["lead"],
|
||||
clips=clips,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
def build_vocal_track(
|
||||
selector, sections, offsets, key, bpm, analysis,
|
||||
def build_clap_track(
|
||||
selector: SampleSelector,
|
||||
sections: list[SectionDef],
|
||||
offsets: list[float],
|
||||
) -> TrackDef:
|
||||
beat_dur = 60.0 / analysis.bpm
|
||||
transient_times = sorted(t.time for t in analysis.transients)
|
||||
used_ids: list[str] = []
|
||||
clips = []
|
||||
"""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)
|
||||
clap_path = snare_results[0].sample["original_path"] if snare_results else None
|
||||
|
||||
for section, sec_off in zip(sections, offsets):
|
||||
char = "powerful" if section.name == "chorus" else "melodic"
|
||||
vs = selector.select_diverse(
|
||||
role="vocal", n=1, exclude=used_ids, key=key, bpm=bpm, character=char,
|
||||
)
|
||||
if not vs:
|
||||
continue
|
||||
vpath = vs[0]["original_path"]
|
||||
sid = vs[0].get("file_hash", "")
|
||||
if sid:
|
||||
used_ids.append(sid)
|
||||
|
||||
if section.name == "chorus":
|
||||
for bar in range(section.bars):
|
||||
bar_start = (sec_off * 4.0 + bar * 4.0) * beat_dur
|
||||
bar_end = bar_start + 4.0 * beat_dur
|
||||
gap_start = bar_start
|
||||
for tt in transient_times:
|
||||
if tt < bar_start:
|
||||
continue
|
||||
if tt > bar_end:
|
||||
break
|
||||
if tt - gap_start > 0.08:
|
||||
bp = sec_off * 4.0 + bar * 4.0 + (gap_start - bar_start) / beat_dur
|
||||
lb = min((tt - gap_start) / beat_dur, 2.0)
|
||||
clips.append(ClipDef(
|
||||
position=bp, length=max(lb, 0.5),
|
||||
name=f"{section.name.capitalize()} Vocal",
|
||||
audio_path=vpath,
|
||||
))
|
||||
gap_start = tt
|
||||
if bar_end - gap_start > 0.08:
|
||||
bp = sec_off * 4.0 + bar * 4.0 + (gap_start - bar_start) / beat_dur
|
||||
clips.append(ClipDef(
|
||||
position=bp, length=max((bar_end - gap_start) / beat_dur, 0.5),
|
||||
name=f"{section.name.capitalize()} Vocal", audio_path=vpath,
|
||||
))
|
||||
else:
|
||||
for bar in range(0, section.bars, 4):
|
||||
clips.append(ClipDef(
|
||||
position=sec_off * 4.0 + bar * 4.0,
|
||||
length=4.0 * min(4, section.bars - bar),
|
||||
name=f"{section.name.capitalize()} Vocal",
|
||||
audio_path=vpath, loop=True,
|
||||
))
|
||||
|
||||
plugins = [make_plugin(fx, i) for i, fx in enumerate(FX_CHAINS.get("vocal", []))]
|
||||
return TrackDef(
|
||||
name="Vocals", volume=VOLUME_LEVELS["vocal"], pan=0.0,
|
||||
color=ROLE_COLORS["vocal"], clips=clips, plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
def build_clap_track(selector, sections, offsets) -> TrackDef:
|
||||
clap_results = selector.select(role="snare", limit=5)
|
||||
clap_path = clap_results[0].sample["original_path"] if clap_results else None
|
||||
clips = []
|
||||
if clap_path:
|
||||
for section, sec_off in zip(sections, offsets):
|
||||
if section.name not in ("chorus", "verse"):
|
||||
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",
|
||||
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,
|
||||
color=ROLE_COLORS["clap"], clips=clips,
|
||||
name="Clap",
|
||||
volume=VOLUME_LEVELS["clap"],
|
||||
pan=0.0,
|
||||
color=ROLE_COLORS["clap"],
|
||||
clips=clips,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
def build_pad_track(sections, offsets, key_root, key_minor) -> TrackDef:
|
||||
root_midi = root_to_midi(key_root, 3)
|
||||
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."""
|
||||
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):
|
||||
vm = section.energy
|
||||
notes = [MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(60 * vm)) for p in chord]
|
||||
notes = [
|
||||
MidiNote(pitch=p, start=0.0, duration=section.bars * 4.0, velocity=int(60 * 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,
|
||||
position=sec_off * 4.0,
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} Pad",
|
||||
midi_notes=notes,
|
||||
))
|
||||
|
||||
plugins = [make_plugin("Omnisphere", 0)]
|
||||
plugins += [make_plugin(fx, i + 1) for i, fx in enumerate(FX_CHAINS.get("pad", []))]
|
||||
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,
|
||||
color=ROLE_COLORS["pad"], clips=clips, plugins=plugins,
|
||||
name="Pad",
|
||||
volume=VOLUME_LEVELS["pad"],
|
||||
pan=0.0,
|
||||
color=ROLE_COLORS["pad"],
|
||||
clips=clips,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Return tracks + backward compat
|
||||
# Phase 3: Mixing — Return tracks and sends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_return_tracks() -> list[TrackDef]:
|
||||
"""Create Reverb and Delay return tracks."""
|
||||
return [
|
||||
TrackDef(
|
||||
name="Reverb", volume=0.7, pan=0.0, clips=[],
|
||||
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=[],
|
||||
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 project from drumloop analysis — instrumental only."
|
||||
)
|
||||
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("--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)
|
||||
|
||||
# ===== 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)
|
||||
|
||||
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 =====
|
||||
sections, offsets = build_section_structure()
|
||||
|
||||
# ===== Step 4: Build all tracks =====
|
||||
total_beats = sum(s.bars for s in sections) * 4.0
|
||||
|
||||
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_clap_track(selector, sections, offsets),
|
||||
build_pad_track(sections, offsets, key_root, key_minor),
|
||||
]
|
||||
|
||||
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
|
||||
for track in all_tracks:
|
||||
if track.name in ("Reverb", "Delay"):
|
||||
continue
|
||||
role = track.name.lower()
|
||||
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")
|
||||
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:
|
||||
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()}")
|
||||
|
||||
|
||||
# Backward compat stubs (used by tests)
|
||||
EFFECT_ALIASES: dict = {}
|
||||
|
||||
def build_section_tracks(*args, **kwargs):
|
||||
return [], []
|
||||
|
||||
|
||||
def build_fx_chain(*args, **kwargs):
|
||||
return []
|
||||
|
||||
@@ -390,123 +644,10 @@ def build_sampler_plugin(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def build_section_tracks(*args, **kwargs):
|
||||
return [], []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compose a REAPER .rpp project from drumloop analysis."
|
||||
)
|
||||
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_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)
|
||||
|
||||
# Step 1: Select drumloop
|
||||
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)
|
||||
|
||||
selector = SampleSelector(str(index_path))
|
||||
selector._load()
|
||||
drumloops = [
|
||||
s for s in selector._samples
|
||||
if s["role"] == "drumloop" and 85 <= s["perceptual"]["tempo"] <= 105
|
||||
]
|
||||
if not drumloops:
|
||||
drumloops = [
|
||||
s for s in selector._samples
|
||||
if s["role"] == "drumloop" and 80 <= s["perceptual"]["tempo"] <= 120
|
||||
]
|
||||
if not drumloops:
|
||||
print("ERROR: No suitable drumloops found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
drumloop = random.choice(drumloops)
|
||||
drumloop_path = drumloop["original_path"]
|
||||
print(f"Selected drumloop: {drumloop.get('original_name', drumloop_path)}")
|
||||
print(f" BPM: {drumloop['perceptual']['tempo']:.1f}, Key: {drumloop['musical']['key']}")
|
||||
|
||||
# Step 2: Analyze drumloop
|
||||
print("Analyzing drumloop...")
|
||||
analyzer = DrumLoopAnalyzer(drumloop_path)
|
||||
analysis = analyzer.analyze()
|
||||
print(f" Detected BPM: {analysis.bpm:.1f}")
|
||||
print(f" Detected Key: {analysis.key}")
|
||||
print(f" Transients: {len(analysis.transients)} "
|
||||
f"(kicks={len(analysis.transients_of_type('kick'))} "
|
||||
f"snares={len(analysis.transients_of_type('snare'))} "
|
||||
f"hihats={len(analysis.transients_of_type('hihat'))})")
|
||||
|
||||
# Step 3: 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 4: 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
|
||||
|
||||
# Step 5: Build tracks
|
||||
total_beats = sum(s.bars for s in sections) * 4.0
|
||||
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_melody_track(analysis, sections, offsets, key_root, key_minor, seed=args.seed or 42),
|
||||
build_vocal_track(selector, sections, offsets, key, bpm, analysis),
|
||||
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
|
||||
|
||||
# Step 6: Wire sends
|
||||
reverb_idx = len(tracks)
|
||||
delay_idx = len(tracks) + 1
|
||||
for track in all_tracks:
|
||||
if track.name not in ("Reverb", "Delay"):
|
||||
role = track.name.lower().replace("vocals", "vocal")
|
||||
sends = SEND_LEVELS.get(role, (0.0, 0.0))
|
||||
track.send_level = {reverb_idx: sends[0], delay_idx: sends[1]}
|
||||
|
||||
# Step 7: Assemble
|
||||
meta = SongMeta(bpm=bpm, key=key, title="Reggaeton Drumloop Track")
|
||||
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:
|
||||
print(f" - {e}", file=sys.stderr)
|
||||
|
||||
builder = RPPBuilder(song, seed=args.seed)
|
||||
builder.write(str(output_path))
|
||||
print(f"\nWritten: {output_path.resolve()}")
|
||||
# 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__":
|
||||
|
||||
@@ -157,7 +157,7 @@ class TestDrumloopFirstTracks:
|
||||
def test_all_tracks_created(self, tmp_path):
|
||||
output = _mock_main(tmp_path)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
for name in ("Drumloop", "Bass", "Chords", "Melody", "Vocals", "Clap", "Pad", "Reverb", "Delay"):
|
||||
for name in ("Drumloop", "Bass", "Chords", "Lead", "Clap", "Pad", "Reverb", "Delay"):
|
||||
assert name in content, f"Expected track '{name}' in output"
|
||||
|
||||
def test_clap_on_dembow_beats(self, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user