#!/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 .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()