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:
144
src/reaper_builder/__init__.py
Normal file
144
src/reaper_builder/__init__.py
Normal 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
|
||||
Reference in New Issue
Block a user