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

@@ -0,0 +1,144 @@
"""REAPER .rpp project builder.
High-level interface: pass a ``core.schema.SongDefinition`` to ``RPPBuilder``
and call ``write()`` to emit a valid .rpp text file.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from rpp import Element, dumps
from ..core.schema import SongDefinition, TrackDef, ClipDef, PluginDef
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_guid() -> str:
"""Generate a random REAPER GUID string."""
return str(uuid.uuid4()).upper()
# ---------------------------------------------------------------------------
# RPPBuilder
# ---------------------------------------------------------------------------
class RPPBuilder:
"""Builds a REAPER .rpp file from a SongDefinition.
Usage::
song = SongDefinition(meta=SongMeta(bpm=95, key="Am", title="Test"))
builder = RPPBuilder(song)
builder.write("output.rpp")
"""
def __init__(self, song: SongDefinition) -> None:
self.song = song
def write(self, path: str | Path) -> None:
"""Serialize the project to a .rpp file at *path*.
Raises:
OSError: If the file cannot be written.
"""
root = self._build_element()
p = Path(path)
p.write_text(dumps(root), encoding="utf-8")
def _build_element(self) -> Element:
"""Build the Element tree for the .rpp file."""
m = self.song.meta
# Project root
root = Element("REAPER_PROJECT", ["0.1", "6.0", str(int(uuid.uuid4().time))])
# TEMPO is a flat attribute line, NOT a child element
root.append(["TEMPO", str(m.bpm), str(m.time_sig_num), str(m.time_sig_den)])
# Master track
master = Element("TRACK", [_make_guid()])
master.append(['NAME', "master"])
master.append(["VOLPAN", "1.0", "0", "-1", "-1", "1"])
root.append(master)
# User tracks
for track in self.song.tracks:
root.append(self._build_track(track))
return root
def _build_track(self, track: TrackDef) -> Element:
"""Build a TRACK Element."""
track_elem = Element("TRACK", [_make_guid()])
track_elem.append(["NAME", track.name])
vol = track.volume
pan = track.pan
track_elem.append([f"VOLPAN", f"{vol:.6f}", f"{pan:.6f}", "-1", "-1", "1"])
if track.color != 0:
track_elem.append(["COLOR", str(track.color)])
# Plugins (FXCHAIN)
if track.plugins:
fxchain = Element("FXCHAIN", [])
for plugin in track.plugins:
fxchain.append(self._build_plugin(plugin))
track_elem.append(fxchain)
# Clips (items)
for clip in track.clips:
track_elem.append(self._build_clip(clip))
return track_elem
def _build_plugin(self, plugin: PluginDef) -> Element:
"""Build a VST Element inside FXCHAIN."""
params_str = " ".join(str(v) for v in plugin.params.values()) if plugin.params else ""
vst = Element("VST", [plugin.name, plugin.path, str(plugin.index), "", *params_str.split(), "0", "0"])
return vst
def _build_clip(self, clip: ClipDef) -> Element:
"""Build an ITEM Element."""
item = Element("ITEM", [])
item.append(["POSITION", str(clip.position)])
item.append(["LENGTH", str(clip.length)])
if clip.name:
item.append(["NAME", clip.name])
if clip.is_audio and clip.audio_path:
source = Element("SOURCE", ["WAVE"])
source.append(["FILE", clip.audio_path])
item.append(source)
elif clip.is_midi:
item.append(self._build_midi_source(clip))
return item
def _build_midi_source(self, clip: ClipDef) -> Element:
"""Build a SOURCE MIDI Element with E-lines."""
source = Element("SOURCE", ["MIDI"])
source.append(["HASDATA", "1", "960", "QN"])
ppq = 960 # ticks per quarter note
sorted_notes = sorted(clip.midi_notes, key=lambda n: n.start)
cursor = 0.0
for note in sorted_notes:
start_ticks = int(note.start * ppq)
delta = start_ticks - cursor
cursor = start_ticks
# Note on: status 90, pitch, velocity
source.append(['E', str(delta), '90', f'{note.pitch:02x}', f'{note.velocity:02x}'])
# Note off after duration
off_delta = int(note.duration * ppq)
source.append(['E', str(off_delta), '80', f'{note.pitch:02x}', '00'])
return source