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:
renato97
2026-05-03 09:13:35 -03:00
parent 1e2316a5a4
commit af6d61c8a1
47 changed files with 1589 additions and 4990 deletions

View File

@@ -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()