feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
251
scripts/compose_track.py
Normal file
251
scripts/compose_track.py
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python
|
||||
"""compose_track.py — CLI para generar un .flp reggaeton completo desde cero."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Agregar project root al path
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
|
||||
from src.selector import SampleSelector
|
||||
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
||||
from src.composer.rhythm import get_notes
|
||||
from src.flp_builder.schema import (
|
||||
SongDefinition, SongMeta, PatternDef, ArrangementTrack,
|
||||
ArrangementItemDef, MelodicTrack, MelodicNote
|
||||
)
|
||||
from src.flp_builder.builder import FLPBuilder
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drum track configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
DRUM_SAMPLE_KEYS = ["channel10", "channel11", "channel12", "channel13",
|
||||
"channel14", "channel15", "channel16"]
|
||||
DRUM_ROLES = ["perc", "kick", "snare", "rim",
|
||||
"perc", "hihat", "clap"]
|
||||
DRUM_CHANNELS = [10, 11, 12, 13,
|
||||
14, 15, 16 ]
|
||||
|
||||
# Pattern definitions for drum tracks (1 pattern per relevant drum instrument)
|
||||
DRUM_PATTERNS = [
|
||||
# id, name, instrument, channel, generator
|
||||
(1, "Kick Main", "kick", 11, "kick_main_notes"),
|
||||
(2, "Snare Basic", "snare", 12, "snare_verse_notes"),
|
||||
(3, "Hihat Straight","hihat", 15, "hihat_16th_notes"),
|
||||
(4, "Clap On2and4", "clap", 16, "clap_24_notes"),
|
||||
(5, "Perc Sparse", "perc", 10, "perc_combo_notes"),
|
||||
]
|
||||
|
||||
# Melodic track configuration: (role, channel_index, volume, pan, generator_fn)
|
||||
MELODIC_CONFIG = [
|
||||
("bass", 17, 0.85, 0.0, bass_tresillo),
|
||||
("lead", 18, 0.75, 0.1, lead_hook),
|
||||
("pad", 19, 0.60, 0.0, pad_sustain),
|
||||
("pluck", 20, 0.70, -0.15, lead_hook), # lead_hook with octave=5
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sampler template — extract once from reference FLP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_sampler_template() -> Path:
|
||||
"""Extract sampler channel template from reference FLP if not cached."""
|
||||
project = Path(__file__).parents[1]
|
||||
template_path = project / "output" / "flstudio_sampler_template.bin"
|
||||
if template_path.exists():
|
||||
return template_path
|
||||
|
||||
ref_flp = project / "my space ryt" / "my space ryt.flp"
|
||||
ch11_path = project / "output" / "ch11_kick_template.bin"
|
||||
|
||||
# Try ch11_kick_template.bin first (legacy name)
|
||||
if ch11_path.exists():
|
||||
return ch11_path
|
||||
|
||||
# Extract channel 11 from reference FLP
|
||||
from src.flp_builder.skeleton import ChannelSkeletonLoader
|
||||
loader = ChannelSkeletonLoader(str(ref_flp), str(ch11_path), str(project / "output" / "samples"))
|
||||
segments = loader._extract_channels_raw()
|
||||
if 11 in segments:
|
||||
template_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
template_path.write_bytes(segments[11])
|
||||
print(f" [OK] Sampler template extracted -> {template_path}")
|
||||
return template_path
|
||||
|
||||
raise FileNotFoundError(
|
||||
"No sampler template found. "
|
||||
"Please ensure output/ch11_kick_template.bin exists, "
|
||||
"or the reference FLP contains channel 11."
|
||||
)
|
||||
|
||||
|
||||
def _build_sample_path(sample: dict) -> str:
|
||||
"""Build absolute path to a sample file.
|
||||
|
||||
The sample dict has ``original_path`` pointing to the source file.
|
||||
We map it to the analyzed library path:
|
||||
librerias/reggaeton/.../role/name.wav →
|
||||
librerias/analyzed_samples/{role}/{new_name}
|
||||
"""
|
||||
role = sample.get("role", "")
|
||||
new_name = sample.get("new_name", "")
|
||||
project = Path(__file__).parents[1]
|
||||
analyzed = project / "librerias" / "analyzed_samples" / role / new_name
|
||||
if analyzed.exists():
|
||||
return str(analyzed)
|
||||
# Fallback: try original_path if it still exists
|
||||
orig = sample.get("original_path", "")
|
||||
if orig and Path(orig).exists():
|
||||
return orig
|
||||
# Last resort: return analyzed path even if missing (let FLPBuilder handle it)
|
||||
return str(analyzed)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Genera un archivo .flp reggaeton completo con drums, bass, lead y pads."
|
||||
)
|
||||
parser.add_argument("--key", default="Am", help="Tonalidad (e.g. Am, Dm, Gm)")
|
||||
parser.add_argument("--bpm", type=float, default=95, help="Tempo BPM")
|
||||
parser.add_argument("--bars", type=int, default=8, help="Duración en bars")
|
||||
parser.add_argument("--output", default="output/composed.flp", help="Ruta del .flp de salida")
|
||||
parser.add_argument("--title", default="Reggaeton Track", help="Título del song")
|
||||
args = parser.parse_args()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Sample selection
|
||||
# ---------------------------------------------------------------------------
|
||||
sel = SampleSelector()
|
||||
samples: dict[str, str] = {}
|
||||
|
||||
for key_name, role, ch in zip(DRUM_SAMPLE_KEYS, DRUM_ROLES, DRUM_CHANNELS):
|
||||
match = sel.select_one(role=role, bpm=args.bpm)
|
||||
if match:
|
||||
samples[key_name] = match["new_name"]
|
||||
else:
|
||||
samples[key_name] = f"{role}.wav"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Drum patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
patterns: list[PatternDef] = []
|
||||
for pid, name, instrument, channel, generator in DRUM_PATTERNS:
|
||||
patterns.append(PatternDef(
|
||||
id=pid,
|
||||
name=name,
|
||||
instrument=instrument,
|
||||
channel=channel,
|
||||
bars=args.bars,
|
||||
generator=generator,
|
||||
velocity_mult=1.0,
|
||||
density=1.0,
|
||||
))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Melodic tracks with sample selection
|
||||
# ---------------------------------------------------------------------------
|
||||
melodic_tracks: list[MelodicTrack] = []
|
||||
|
||||
for role, ch_idx, vol, pan, generator_fn in MELODIC_CONFIG:
|
||||
match = sel.select_one(role=role, key=args.key, bpm=args.bpm)
|
||||
if match is None:
|
||||
print(f" [WARN] No sample found for role '{role}', skipping.")
|
||||
continue
|
||||
|
||||
# Build notes using the generator
|
||||
if role == "pluck":
|
||||
raw_notes = generator_fn(args.key, bars=args.bars, octave=5)
|
||||
else:
|
||||
raw_notes = generator_fn(args.key, bars=args.bars)
|
||||
|
||||
notes = [
|
||||
MelodicNote(pos=n["pos"], len=n["len"], key=n["key"], vel=n["vel"])
|
||||
for n in raw_notes
|
||||
]
|
||||
|
||||
sample_path = _build_sample_path(match)
|
||||
|
||||
melodic_tracks.append(MelodicTrack(
|
||||
role=role,
|
||||
sample_path=sample_path,
|
||||
notes=notes,
|
||||
channel_index=ch_idx,
|
||||
volume=vol,
|
||||
pan=pan,
|
||||
))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Arrangement tracks and items
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracks: 1 drum track + 1 per melodic track
|
||||
tracks: list[ArrangementTrack] = [
|
||||
ArrangementTrack(index=1, name="Drums"),
|
||||
]
|
||||
for i, mt in enumerate(melodic_tracks):
|
||||
tracks.append(ArrangementTrack(index=2 + i, name=mt.role.capitalize()))
|
||||
|
||||
# Items: each drum pattern placed at bar 0
|
||||
items: list[ArrangementItemDef] = []
|
||||
for p in patterns:
|
||||
items.append(ArrangementItemDef(
|
||||
pattern=p.id,
|
||||
bar=0.0,
|
||||
bars=float(args.bars),
|
||||
track=1, # all drum patterns on the Drums track
|
||||
muted=False,
|
||||
))
|
||||
|
||||
# Melodic items are added by FLPBuilder._build_arrangement (auto-added at bar 0)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build and save
|
||||
# ---------------------------------------------------------------------------
|
||||
meta = SongMeta(
|
||||
bpm=args.bpm,
|
||||
key=args.key,
|
||||
title=args.title,
|
||||
ppq=96,
|
||||
time_sig_num=4,
|
||||
time_sig_den=4,
|
||||
)
|
||||
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
samples=samples,
|
||||
patterns=patterns,
|
||||
tracks=tracks,
|
||||
items=items,
|
||||
melodic_tracks=melodic_tracks,
|
||||
)
|
||||
|
||||
errors = song.validate()
|
||||
if errors:
|
||||
print("Validation errors:")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure sampler template exists before building
|
||||
template_path = _ensure_sampler_template()
|
||||
project = Path(__file__).parents[1]
|
||||
builder = FLPBuilder(
|
||||
ref_flp=str(project / "my space ryt" / "my space ryt.flp"),
|
||||
ch11_template=str(template_path),
|
||||
samples_dir=str(project / "librerias" / "analyzed_samples"),
|
||||
)
|
||||
flp_bytes = builder.build(song)
|
||||
|
||||
out_path = Path(args.output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(flp_bytes)
|
||||
|
||||
print(f"[OK] FLP generado: {out_path} ({len(flp_bytes):,} bytes)")
|
||||
print(f" Key: {args.key} | BPM: {args.bpm} | Bars: {args.bars}")
|
||||
print(f" Patterns: {len(patterns)} drum + {len(melodic_tracks)} melodic")
|
||||
print(f" Tracks: {len(tracks)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user