Replace FL Studio binary .flp output with REAPER text-based .rpp output using the rpp Python library (Perlence/rpp). - Add core/schema.py: DAW-agnostic data types (SongDefinition, TrackDef, ClipDef, MidiNote, PluginDef) - Add reaper_builder/: RPP file generation via rpp.Element + headless render via reaper.exe CLI - Add composer/converters.py: bridge rhythm.py/melodic.py note dicts to core.schema MidiNote objects - Rewrite scripts/compose.py: real generator pipeline with --render flag - Delete src/flp_builder/, src/scanner/, mcp/, flstudio-mcp/, old scripts - Add 40 passing tests (schema, builder, converters, compose, render)
205 lines
6.3 KiB
Python
205 lines
6.3 KiB
Python
#!/usr/bin/env python
|
|
"""Compose a REAPER .rpp project from the sample library.
|
|
|
|
Single entrypoint: loads sample index, builds a SongDefinition from the selector/composer,
|
|
and writes a .rpp file.
|
|
|
|
Usage:
|
|
python scripts/compose.py --genre reggaeton --bpm 95 --key Am
|
|
python scripts/compose.py --genre trap --bpm 140 --key Cm --output output/my_track.rpp
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure project root on path
|
|
_ROOT = Path(__file__).parent.parent
|
|
sys.path.insert(0, str(_ROOT))
|
|
|
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
|
from src.composer.rhythm import get_notes
|
|
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
|
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
|
from src.selector import SampleSelector
|
|
from src.reaper_builder import RPPBuilder
|
|
from src.reaper_builder.render import render_project
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Track builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_drum_track(
|
|
role: str,
|
|
generator_name: str,
|
|
bars: int,
|
|
) -> TrackDef:
|
|
"""Build a drum MIDI track from a rhythm generator.
|
|
|
|
Args:
|
|
role: Track name (e.g. "kick", "snare")
|
|
generator_name: Name from rhythm.GENERATORS (e.g. "kick_main_notes")
|
|
bars: Number of bars
|
|
"""
|
|
note_dict = get_notes(generator_name, bars)
|
|
midi_notes = rhythm_to_midi(note_dict)
|
|
clip = ClipDef(
|
|
position=0.0,
|
|
length=bars * 4.0,
|
|
name=f"{role.capitalize()} Pattern",
|
|
midi_notes=midi_notes,
|
|
)
|
|
return TrackDef(name=role.capitalize(), clips=[clip])
|
|
|
|
|
|
def build_melodic_track(
|
|
role: str,
|
|
generator_fn,
|
|
key: str,
|
|
bpm: float,
|
|
bars: int,
|
|
selector: SampleSelector | None = None,
|
|
) -> TrackDef:
|
|
"""Build a melodic MIDI track from a generator function.
|
|
|
|
Args:
|
|
role: Track name (e.g. "bass", "lead")
|
|
generator_fn: Callable from melodic.py (e.g. bass_tresillo)
|
|
key: Musical key (e.g. "Am")
|
|
bpm: Tempo for sample selection
|
|
bars: Number of bars
|
|
selector: Optional SampleSelector; if provided, sets audio_path on ClipDef
|
|
"""
|
|
note_list = generator_fn(key=key, bars=bars)
|
|
midi_notes = melodic_to_midi(note_list)
|
|
|
|
audio_path: str | None = None
|
|
if selector is not None:
|
|
match = selector.select_one(role=role, key=key, bpm=bpm)
|
|
if match:
|
|
audio_path = match.get("original_path", None)
|
|
|
|
clip = ClipDef(
|
|
position=0.0,
|
|
length=bars * 4.0,
|
|
name=f"{role.capitalize()} MIDI",
|
|
audio_path=audio_path,
|
|
midi_notes=midi_notes,
|
|
)
|
|
return TrackDef(name=role.capitalize(), clips=[clip])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Compose a REAPER .rpp project from the sample library."
|
|
)
|
|
parser.add_argument(
|
|
"--genre",
|
|
default="reggaeton",
|
|
help="Genre (default: reggaeton)",
|
|
)
|
|
parser.add_argument(
|
|
"--bpm",
|
|
type=float,
|
|
default=95.0,
|
|
help="BPM (default: 95)",
|
|
)
|
|
parser.add_argument(
|
|
"--key",
|
|
default="Am",
|
|
help="Musical key (default: Am)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="output/track.rpp",
|
|
help="Output .rpp path (default: output/track.rpp)",
|
|
)
|
|
parser.add_argument(
|
|
"--render",
|
|
action="store_true",
|
|
help="Render the project to WAV after generating the .rpp file.",
|
|
)
|
|
parser.add_argument(
|
|
"--render-output",
|
|
default=None,
|
|
help="Output WAV path for rendering. Defaults to <output>.wav with .rpp extension replaced.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Validate BPM before any writes
|
|
if args.bpm <= 0:
|
|
raise ValueError(f"bpm must be > 0, got {args.bpm}")
|
|
|
|
# Ensure output directory exists
|
|
output_path = Path(args.output)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Load sample index (for melodic tracks that use audio samples)
|
|
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))
|
|
|
|
# Determine bar count from genre
|
|
genre_bar_map = {
|
|
"reggaeton": 64,
|
|
"trap": 32,
|
|
"house": 64,
|
|
"drill": 32,
|
|
}
|
|
bar_count = genre_bar_map.get(args.genre.lower(), 48)
|
|
|
|
# Build drum tracks (no selector needed)
|
|
drum_tracks = [
|
|
build_drum_track("kick", "kick_main_notes", bar_count),
|
|
build_drum_track("snare", "snare_verse_notes", bar_count),
|
|
build_drum_track("hihat", "hihat_16th_notes", bar_count),
|
|
build_drum_track("perc", "perc_combo_notes", bar_count),
|
|
]
|
|
|
|
# Build melodic tracks (selector passed only to bass)
|
|
melodic_tracks = [
|
|
build_melodic_track("bass", bass_tresillo, args.key, args.bpm, bar_count, selector),
|
|
build_melodic_track("lead", lead_hook, args.key, args.bpm, bar_count),
|
|
build_melodic_track("chords", chords_block, args.key, args.bpm, bar_count),
|
|
build_melodic_track("pad", pad_sustain, args.key, args.bpm, bar_count),
|
|
]
|
|
|
|
# Assemble full track list
|
|
all_tracks = drum_tracks + melodic_tracks
|
|
|
|
# Build SongDefinition
|
|
meta = SongMeta(bpm=args.bpm, key=args.key, title=f"{args.genre.capitalize()} Track")
|
|
song = SongDefinition(meta=meta, tracks=all_tracks)
|
|
|
|
# Validate
|
|
errors = song.validate()
|
|
if errors:
|
|
print(f"WARNING: SongDefinition has validation errors:", file=sys.stderr)
|
|
for e in errors:
|
|
print(f" - {e}", file=sys.stderr)
|
|
|
|
# Write .rpp
|
|
builder = RPPBuilder(song)
|
|
builder.write(str(output_path))
|
|
|
|
# Render if requested
|
|
if args.render:
|
|
render_output_path = args.render_output
|
|
if render_output_path is None:
|
|
render_output_path = str(output_path).replace('.rpp', '.wav')
|
|
render_project(str(output_path), render_output_path)
|
|
|
|
print(str(output_path.resolve()))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |