refactor: migrate from FL Studio to REAPER with rpp library
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)
This commit is contained in:
@@ -1,71 +1,205 @@
|
||||
#!/usr/bin/env python
|
||||
"""Compose and build in one step from genre knowledge base."""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
"""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
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
# Ensure project root on path
|
||||
_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from src.composer import compose_from_genre
|
||||
from scripts.build import build_project
|
||||
from src.flp_builder.writer import FLPWriter
|
||||
|
||||
KNOWLEDGE_DIR = Path(__file__).parent.parent / "knowledge" / "genres"
|
||||
OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
||||
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
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compose and build from genre")
|
||||
parser.add_argument("genre", help="Genre filename (e.g. reggaeton_2009)")
|
||||
parser.add_argument("--key", "-k", default=None, help="Override key (e.g. Am)")
|
||||
parser.add_argument("--bpm", "-b", type=float, default=None, help="Override BPM")
|
||||
parser.add_argument("--bars", type=int, default=None, help="Override bar count")
|
||||
parser.add_argument("--output", "-o", default=None, help="Output .flp path")
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
genre_file = KNOWLEDGE_DIR / f"{args.genre}.json"
|
||||
if not genre_file.exists():
|
||||
print(json.dumps({"error": f"Genre not found: {genre_file}", "available": [p.stem for p in KNOWLEDGE_DIR.glob("*.json")]}))
|
||||
# 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)
|
||||
|
||||
overrides = {}
|
||||
if args.key:
|
||||
overrides["keys"] = [args.key]
|
||||
if args.bpm:
|
||||
overrides["bpm"] = {"default": args.bpm}
|
||||
if args.bars:
|
||||
overrides["structure"] = {"sections": [{"bars": args.bars}]}
|
||||
selector = SampleSelector(str(index_path))
|
||||
|
||||
composition = compose_from_genre(str(genre_file), overrides if overrides else None)
|
||||
project = build_project(composition)
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
output_path = args.output or str(
|
||||
OUTPUT_DIR / f"{args.genre}_{composition['meta']['key']}_{composition['meta']['bpm']}bpm.flp"
|
||||
)
|
||||
|
||||
writer = FLPWriter(project)
|
||||
writer.write(output_path)
|
||||
|
||||
result = {
|
||||
"status": "ok",
|
||||
"output": output_path,
|
||||
"genre": args.genre,
|
||||
"key": composition["meta"]["key"],
|
||||
"bpm": composition["meta"]["bpm"],
|
||||
"chord_progression": composition["meta"]["chord_progression"],
|
||||
"tracks": [
|
||||
{"role": t["role"], "notes": len(t.get("notes", []))}
|
||||
for t in composition["tracks"]
|
||||
],
|
||||
"channel_names": [ch.name for ch in project.channels],
|
||||
"total_notes": sum(len(n) for t in composition["tracks"] for n in t.get("notes", [])),
|
||||
# Determine bar count from genre
|
||||
genre_bar_map = {
|
||||
"reggaeton": 64,
|
||||
"trap": 32,
|
||||
"house": 64,
|
||||
"drill": 32,
|
||||
}
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
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()
|
||||
main()
|
||||
Reference in New Issue
Block a user