Files
reaper-control/scripts/compose.py
renato97 af6d61c8a1 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)
2026-05-03 09:13:35 -03:00

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