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:
@@ -1,3 +0,0 @@
|
|||||||
@echo off
|
|
||||||
python scripts\compose_track.py --key Am --bpm 95 --bars 8 --output output\reggaeton.flp
|
|
||||||
pause
|
|
||||||
Submodule flstudio-mcp deleted from d518dec361
@@ -1,5 +0,0 @@
|
|||||||
"""FL Studio MCP Server — nibble-encoded SysEx over MIDI loopback."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""FL-MCP Protocol — SysEx encoding/decoding and MIDI transport."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .sysex import encode_command, decode_command, nibble_encode, nibble_decode, SYSEX_ID
|
|
||||||
from .transport import MidiTransport
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"nibble_encode",
|
|
||||||
"nibble_decode",
|
|
||||||
"encode_command",
|
|
||||||
"decode_command",
|
|
||||||
"SYSEX_ID",
|
|
||||||
"MidiTransport",
|
|
||||||
]
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""
|
|
||||||
FL-MCP Protocol — SysEx encoding/decoding.
|
|
||||||
|
|
||||||
Protocol: F0 7D [nibble-encoded UTF-8 JSON] F7
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
SYSEX_ID = 0x7D
|
|
||||||
|
|
||||||
|
|
||||||
def nibble_encode(data: bytes) -> list[int]:
|
|
||||||
"""Split each byte into two nibbles (high, low) for MIDI data byte compliance (< 0x80).
|
|
||||||
|
|
||||||
Each byte 0x00-0xFF is split into two MIDI-safe bytes:
|
|
||||||
- high nibble: (byte >> 4) & 0x0F
|
|
||||||
- low nibble: byte & 0x0F
|
|
||||||
"""
|
|
||||||
result: list[int] = []
|
|
||||||
for byte in data:
|
|
||||||
result.append((byte >> 4) & 0x0F)
|
|
||||||
result.append(byte & 0x0F)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def nibble_decode(nibbles: list[int]) -> bytes:
|
|
||||||
"""Reconstruct bytes from nibble pairs.
|
|
||||||
|
|
||||||
Each pair (high, low) reconstructs one byte:
|
|
||||||
byte = (high << 4) | low
|
|
||||||
"""
|
|
||||||
result = bytearray()
|
|
||||||
for i in range(0, len(nibbles), 2):
|
|
||||||
if i + 1 < len(nibbles):
|
|
||||||
result.append(((nibbles[i] & 0x0F) << 4) | (nibbles[i + 1] & 0x0F))
|
|
||||||
return bytes(result)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_command(cmd: str, params: dict | None = None) -> list[int]:
|
|
||||||
"""Encode a JSON command as a complete SysEx message: F0 7D [nibbles...] F7."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
payload = json.dumps({"cmd": cmd, "params": params or {}})
|
|
||||||
nibbles = nibble_encode(payload.encode("utf-8"))
|
|
||||||
return [0xF0, SYSEX_ID] + nibbles + [0xF7]
|
|
||||||
|
|
||||||
|
|
||||||
def decode_command(data: list[int]) -> dict | None:
|
|
||||||
"""Decode a SysEx message back to JSON dict.
|
|
||||||
|
|
||||||
Returns None if the message is not a valid FL-MCP SysEx message.
|
|
||||||
"""
|
|
||||||
if not data or len(data) < 4 or data[0] != 0xF0 or data[-1] != 0xF7:
|
|
||||||
return None
|
|
||||||
if data[1] != SYSEX_ID:
|
|
||||||
return None
|
|
||||||
import json
|
|
||||||
|
|
||||||
nibbles = data[2:-1]
|
|
||||||
if not nibbles:
|
|
||||||
return None
|
|
||||||
raw = nibble_decode(nibbles)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
return json.loads(raw.decode("utf-8"))
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""FL-MCP MIDI Transport using mido."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import mido
|
|
||||||
from mido import Message
|
|
||||||
|
|
||||||
from .sysex import encode_command
|
|
||||||
|
|
||||||
|
|
||||||
class MidiTransport:
|
|
||||||
"""MIDI transport for sending SysEx commands to FL Studio via loopback."""
|
|
||||||
|
|
||||||
def __init__(self, port_name: str = "FL_MCP"):
|
|
||||||
self.port_name = port_name
|
|
||||||
self._output: mido.ports.Port | None = None
|
|
||||||
self._input: mido.ports.Port | None = None
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
"""Find and open the FL_MCP output port."""
|
|
||||||
ports = mido.get_output_names()
|
|
||||||
# Try exact match first, then partial
|
|
||||||
match: str | None = None
|
|
||||||
for p in ports:
|
|
||||||
if self.port_name in p:
|
|
||||||
match = p
|
|
||||||
break
|
|
||||||
if not match:
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Port '{self.port_name}' not found. Available: {ports}"
|
|
||||||
)
|
|
||||||
self._output = mido.open_output(match)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def send_command(self, cmd: str, params: dict | None = None) -> None:
|
|
||||||
"""Send a SysEx command to FL Studio."""
|
|
||||||
if not self._output:
|
|
||||||
self.connect()
|
|
||||||
data = encode_command(cmd, params)
|
|
||||||
# mido.Message('sysex', data=...) automatically wraps with F0/F7
|
|
||||||
# data[1:-1] skips the F0/SYSEX_ID/F7 wrapper since mido adds them
|
|
||||||
msg = Message("sysex", data=bytes(data[1:-1]))
|
|
||||||
self._output.send(msg)
|
|
||||||
|
|
||||||
def receive(self, timeout: float = 1.0) -> mido.Message | None:
|
|
||||||
"""Receive a MIDI message (blocking with timeout)."""
|
|
||||||
if not self._input:
|
|
||||||
self._input = mido.open_input(
|
|
||||||
next((p for p in mido.get_input_names() if self.port_name in p), None)
|
|
||||||
)
|
|
||||||
if self._input:
|
|
||||||
return self._input.receive(timeout=timeout)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Close all MIDI ports."""
|
|
||||||
if self._output:
|
|
||||||
self._output.close()
|
|
||||||
self._output = None
|
|
||||||
if self._input:
|
|
||||||
self._input.close()
|
|
||||||
self._input = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def list_ports() -> dict[str, list[str]]:
|
|
||||||
"""List all available MIDI input and output ports."""
|
|
||||||
return {"inputs": mido.get_input_names(), "outputs": mido.get_output_names()}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""FL Studio MCP Server entry point."""
|
|
||||||
from server import mcp
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
mcp.run(transport="stdio")
|
|
||||||
356
mcp/server.py
356
mcp/server.py
@@ -1,356 +0,0 @@
|
|||||||
"""
|
|
||||||
FL Studio MCP Server — FastMCP server with MIDI SysEx transport.
|
|
||||||
|
|
||||||
Sends nibble-encoded JSON commands to FL Studio via Windows MIDI Services loopback.
|
|
||||||
FL Studio controller script receives via OnSysEx() and calls FL Studio API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
|
|
||||||
from protocol.transport import MidiTransport
|
|
||||||
|
|
||||||
mcp = FastMCP("fl-studio-mcp")
|
|
||||||
transport = MidiTransport()
|
|
||||||
|
|
||||||
|
|
||||||
def _log(msg: str) -> None:
|
|
||||||
"""Print a log message to stderr."""
|
|
||||||
print(f"[fl-studio-mcp] {msg}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Transport Tools ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def play() -> str:
|
|
||||||
"""Start FL Studio playback."""
|
|
||||||
try:
|
|
||||||
transport.send_command("start_playback")
|
|
||||||
return "Playback started"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def stop() -> str:
|
|
||||||
"""Stop FL Studio playback."""
|
|
||||||
try:
|
|
||||||
transport.send_command("stop_playback")
|
|
||||||
return "Playback stopped"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def record() -> str:
|
|
||||||
"""Start recording in FL Studio."""
|
|
||||||
try:
|
|
||||||
transport.send_command("start_recording")
|
|
||||||
return "Recording started"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def set_tempo(bpm: float) -> str:
|
|
||||||
"""Set FL Studio tempo (40-999 BPM)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_tempo", {"tempo": float(bpm)})
|
|
||||||
return f"Tempo set to {bpm} BPM"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def set_time_signature(numerator: int = 4, denominator: int = 4) -> str:
|
|
||||||
"""Set time signature."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_time_signature", {
|
|
||||||
"numerator": int(numerator),
|
|
||||||
"denominator": int(denominator),
|
|
||||||
})
|
|
||||||
return f"Time signature set to {numerator}/{denominator}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Channel Tools ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def select_channel(channel_index: int) -> str:
|
|
||||||
"""Select a channel in the channel rack (0-based index)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("select_channel", {"channel_index": int(channel_index)})
|
|
||||||
return f"Channel {channel_index} selected"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def mute_channel(channel_index: int, muted: bool = True) -> str:
|
|
||||||
"""Mute or unmute a channel."""
|
|
||||||
try:
|
|
||||||
transport.send_command("mute_channel", {
|
|
||||||
"channel_index": int(channel_index),
|
|
||||||
"muted": bool(muted),
|
|
||||||
})
|
|
||||||
return f"Channel {channel_index} muted={muted}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def solo_channel(channel_index: int) -> str:
|
|
||||||
"""Solo a channel."""
|
|
||||||
try:
|
|
||||||
transport.send_command("solo_channel", {"channel_index": int(channel_index)})
|
|
||||||
return f"Channel {channel_index} soloed"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def set_channel_volume(channel_index: int, volume: float) -> str:
|
|
||||||
"""Set channel volume (0.0-1.0)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_channel_volume", {
|
|
||||||
"channel_index": int(channel_index),
|
|
||||||
"volume": float(volume),
|
|
||||||
})
|
|
||||||
return f"Channel {channel_index} volume={volume}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def set_channel_pan(channel_index: int, pan: float) -> str:
|
|
||||||
"""Set channel pan (-1.0 to 1.0)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_channel_pan", {
|
|
||||||
"channel_index": int(channel_index),
|
|
||||||
"pan": float(pan),
|
|
||||||
})
|
|
||||||
return f"Channel {channel_index} pan={pan}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def note_on(channel_index: int, note: int, velocity: int = 100) -> str:
|
|
||||||
"""Send a note on event."""
|
|
||||||
try:
|
|
||||||
transport.send_command("note_on", {
|
|
||||||
"channel_index": int(channel_index),
|
|
||||||
"note": int(note),
|
|
||||||
"velocity": int(velocity),
|
|
||||||
})
|
|
||||||
return f"Note on: ch={channel_index} note={note} vel={velocity}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def note_off(channel_index: int, note: int) -> str:
|
|
||||||
"""Send a note off event."""
|
|
||||||
try:
|
|
||||||
transport.send_command("note_off", {
|
|
||||||
"channel_index": int(channel_index),
|
|
||||||
"note": int(note),
|
|
||||||
})
|
|
||||||
return f"Note off: ch={channel_index} note={note}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def stop_all_notes() -> str:
|
|
||||||
"""Stop all playing notes (panic)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("stop_all_notes")
|
|
||||||
return "All notes stopped"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Mixer Tools ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def set_mixer_volume(track_index: int, volume: float) -> str:
|
|
||||||
"""Set mixer track volume (0.0-1.25)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_mixer_volume", {
|
|
||||||
"track_index": int(track_index),
|
|
||||||
"volume": float(volume),
|
|
||||||
})
|
|
||||||
return f"Mixer track {track_index} volume={volume}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def set_mixer_pan(track_index: int, pan: float) -> str:
|
|
||||||
"""Set mixer track pan (-1.0 to 1.0)."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_mixer_pan", {
|
|
||||||
"track_index": int(track_index),
|
|
||||||
"pan": float(pan),
|
|
||||||
})
|
|
||||||
return f"Mixer track {track_index} pan={pan}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def mute_mixer_track(track_index: int, muted: bool = True) -> str:
|
|
||||||
"""Mute or unmute a mixer track."""
|
|
||||||
try:
|
|
||||||
transport.send_command("mute_mixer_track", {
|
|
||||||
"track_index": int(track_index),
|
|
||||||
"muted": bool(muted),
|
|
||||||
})
|
|
||||||
return f"Mixer track {track_index} muted={muted}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def solo_mixer_track(track_index: int) -> str:
|
|
||||||
"""Solo a mixer track."""
|
|
||||||
try:
|
|
||||||
transport.send_command("solo_mixer_track", {"track_index": int(track_index)})
|
|
||||||
return f"Mixer track {track_index} soloed"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def rename_mixer_track(track_index: int, name: str) -> str:
|
|
||||||
"""Rename a mixer track."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_mixer_track_name", {
|
|
||||||
"track_index": int(track_index),
|
|
||||||
"name": str(name),
|
|
||||||
})
|
|
||||||
return f"Mixer track {track_index} renamed to '{name}'"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Pattern Tools ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def select_pattern(pattern_index: int) -> str:
|
|
||||||
"""Select a pattern by index."""
|
|
||||||
try:
|
|
||||||
transport.send_command("select_pattern", {"pattern_index": int(pattern_index)})
|
|
||||||
return f"Pattern {pattern_index} selected"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def create_pattern(name: str) -> str:
|
|
||||||
"""Create a new pattern with the given name."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_pattern_name", {
|
|
||||||
"pattern_index": 0, # Will create new
|
|
||||||
"name": str(name),
|
|
||||||
})
|
|
||||||
return f"Pattern '{name}' created"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def rename_pattern(pattern_index: int, name: str) -> str:
|
|
||||||
"""Rename a pattern."""
|
|
||||||
try:
|
|
||||||
transport.send_command("set_pattern_name", {
|
|
||||||
"pattern_index": int(pattern_index),
|
|
||||||
"name": str(name),
|
|
||||||
})
|
|
||||||
return f"Pattern {pattern_index} renamed to '{name}'"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── UI Tools ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def show_channel_rack() -> str:
|
|
||||||
"""Show the FL Studio channel rack window."""
|
|
||||||
try:
|
|
||||||
transport.send_command("show_channel_rack")
|
|
||||||
return "Channel rack shown"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def show_mixer() -> str:
|
|
||||||
"""Show the FL Studio mixer window."""
|
|
||||||
try:
|
|
||||||
transport.send_command("show_mixer")
|
|
||||||
return "Mixer shown"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def show_piano_roll() -> str:
|
|
||||||
"""Show the FL Studio piano roll window."""
|
|
||||||
try:
|
|
||||||
transport.send_command("show_piano_roll")
|
|
||||||
return "Piano roll shown"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def show_playlist() -> str:
|
|
||||||
"""Show the FL Studio playlist window."""
|
|
||||||
try:
|
|
||||||
transport.send_command("show_playlist")
|
|
||||||
return "Playlist shown"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Meta Tools ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def ping() -> str:
|
|
||||||
"""Ping the FL Studio MCP server to verify connectivity."""
|
|
||||||
try:
|
|
||||||
transport.send_command("ping", {"ts": 1})
|
|
||||||
return "pong"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def list_ports() -> dict[str, list[str]]:
|
|
||||||
"""List all available MIDI input and output ports."""
|
|
||||||
return MidiTransport.list_ports()
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def get_session_info() -> dict:
|
|
||||||
"""Get FL Studio session information (transport state)."""
|
|
||||||
return {
|
|
||||||
"server": "fl-studio-mcp",
|
|
||||||
"transport": "midi_sysex",
|
|
||||||
"loopback": "FL_MCP",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
mcp.run(transport="stdio")
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Quick validation of the protocol module."""
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, "C:\\Users\\Administrator\\Documents\\fl_control\\mcp")
|
|
||||||
|
|
||||||
from protocol.sysex import nibble_encode, nibble_decode, encode_command, decode_command, SYSEX_ID
|
|
||||||
|
|
||||||
# Test 1: nibble roundtrip
|
|
||||||
for original in [b"Hello", b"", b"\x00\x7f", b'{"cmd":"ping"}']:
|
|
||||||
encoded = nibble_encode(original)
|
|
||||||
decoded = nibble_decode(encoded)
|
|
||||||
assert decoded == original, f"Roundtrip failed for {original}"
|
|
||||||
print("PASS: nibble roundtrip")
|
|
||||||
|
|
||||||
# Test 2: encode/decode command
|
|
||||||
result = encode_command("ping", {"ts": 1})
|
|
||||||
assert result[0] == 0xF0 and result[1] == 0x7D and result[-1] == 0xF7
|
|
||||||
decoded = decode_command(result)
|
|
||||||
assert decoded["cmd"] == "ping"
|
|
||||||
assert decoded["params"] == {"ts": 1}
|
|
||||||
print("PASS: encode/decode command")
|
|
||||||
|
|
||||||
# Test 3: SYSEX_ID
|
|
||||||
assert SYSEX_ID == 0x7D
|
|
||||||
print("PASS: SYSEX_ID == 0x7D")
|
|
||||||
|
|
||||||
print("All unit tests passed!")
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Send a SysEx ping to FL Studio and check if it arrives."""
|
|
||||||
import mido
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
print("=== FL Studio MCP — Send Ping Test ===")
|
|
||||||
|
|
||||||
# List ports
|
|
||||||
print(f"Inputs: {mido.get_input_names()}")
|
|
||||||
print(f"Outputs: {mido.get_output_names()}")
|
|
||||||
|
|
||||||
# Open FL_MCP 1 output
|
|
||||||
out = mido.open_output("FL_MCP 1")
|
|
||||||
print("Opened FL_MCP 1 output")
|
|
||||||
|
|
||||||
# Encode ping command
|
|
||||||
payload = json.dumps({"cmd": "ping", "params": {"ts": 1}}).encode("utf-8")
|
|
||||||
nibbles = []
|
|
||||||
for b in payload:
|
|
||||||
nibbles.append((b >> 4) & 0x0F)
|
|
||||||
nibbles.append(b & 0x0F)
|
|
||||||
|
|
||||||
# mido adds F0/F7 automatically, we provide [SYSEX_ID + nibble data]
|
|
||||||
msg = mido.Message("sysex", data=bytes([0x7D] + nibbles))
|
|
||||||
hex_str = " ".join(f"{b:02X}" for b in msg.bytes())
|
|
||||||
print(f"Sending SysEx ({len(msg.bytes())} bytes): {hex_str}")
|
|
||||||
out.send(msg)
|
|
||||||
print("Sent! Check FL Studio script console for: [FL-MCP] Ping received")
|
|
||||||
|
|
||||||
# Also try a play command
|
|
||||||
time.sleep(0.5)
|
|
||||||
payload2 = json.dumps({"cmd": "start_playback", "params": {}}).encode("utf-8")
|
|
||||||
nibbles2 = []
|
|
||||||
for b in payload2:
|
|
||||||
nibbles2.append((b >> 4) & 0x0F)
|
|
||||||
nibbles2.append(b & 0x0F)
|
|
||||||
msg2 = mido.Message("sysex", data=bytes([0x7D] + nibbles2))
|
|
||||||
hex_str2 = " ".join(f"{b:02X}" for b in msg2.bytes())
|
|
||||||
print(f"\nSending PLAY SysEx ({len(msg2.bytes())} bytes): {hex_str2}")
|
|
||||||
out.send(msg2)
|
|
||||||
print("Sent! FL Studio should start playing if controller is loaded.")
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
|
||||||
out.close()
|
|
||||||
print("\nDone. Check FL Studio console and transport state.")
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration tests for FL Studio MCP server.
|
|
||||||
|
|
||||||
Tests nibble encode/decode roundtrip, command encoding, and tool registration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, "C:\\Users\\Administrator\\Documents\\fl_control\\mcp")
|
|
||||||
|
|
||||||
from protocol.sysex import nibble_encode, nibble_decode, encode_command, decode_command, SYSEX_ID
|
|
||||||
from protocol.transport import MidiTransport
|
|
||||||
|
|
||||||
|
|
||||||
def test_nibble_encode_decode_roundtrip():
|
|
||||||
"""Test that nibble_encode and nibble_decode are perfect inverses."""
|
|
||||||
test_cases = [
|
|
||||||
b"Hello",
|
|
||||||
b"",
|
|
||||||
b"\x00\x7f\x80\xff",
|
|
||||||
b'{"cmd":"ping","params":{"ts":1}}',
|
|
||||||
b"\xff\xfe\xfd\xfc\xfb",
|
|
||||||
b"A" * 256, # stress test
|
|
||||||
]
|
|
||||||
for original in test_cases:
|
|
||||||
encoded = nibble_encode(original)
|
|
||||||
# Each byte becomes 2 nibbles
|
|
||||||
assert len(encoded) == len(original) * 2, f"Length mismatch for {original!r}"
|
|
||||||
decoded = nibble_decode(encoded)
|
|
||||||
assert decoded == original, f"Roundtrip failed for {original!r}: got {decoded!r}"
|
|
||||||
print("PASS: nibble_encode/decode roundtrip")
|
|
||||||
|
|
||||||
|
|
||||||
def test_encode_command_format():
|
|
||||||
"""Test that encode_command produces valid SysEx format."""
|
|
||||||
# Test 1: Basic command
|
|
||||||
result = encode_command("ping", {"ts": 1})
|
|
||||||
assert result[0] == 0xF0, "Must start with F0"
|
|
||||||
assert result[1] == SYSEX_ID, "Must have SYSEX_ID=0x7D"
|
|
||||||
assert result[-1] == 0xF7, "Must end with F7"
|
|
||||||
print("PASS: encode_command format (basic)")
|
|
||||||
|
|
||||||
# Test 2: Empty params
|
|
||||||
result2 = encode_command("play")
|
|
||||||
assert result2[0] == 0xF0
|
|
||||||
assert result2[1] == SYSEX_ID
|
|
||||||
assert result2[-1] == 0xF7
|
|
||||||
print("PASS: encode_command format (no params)")
|
|
||||||
|
|
||||||
# Test 3: Command decodes back to original JSON
|
|
||||||
original_cmd = "set_tempo"
|
|
||||||
original_params = {"tempo": 140.0}
|
|
||||||
encoded = encode_command(original_cmd, original_params)
|
|
||||||
decoded = decode_command(encoded)
|
|
||||||
assert decoded is not None
|
|
||||||
assert decoded["cmd"] == original_cmd
|
|
||||||
assert decoded["params"] == original_params
|
|
||||||
print("PASS: encode_command → decode_command roundtrip")
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_command_invalid():
|
|
||||||
"""Test that decode_command returns None for invalid input."""
|
|
||||||
assert decode_command([]) is None
|
|
||||||
assert decode_command([0xF0]) is None # too short
|
|
||||||
assert decode_command([0xF0, 0x7D]) is None # too short
|
|
||||||
assert decode_command([0xF0, 0x00, 0xF7]) is None # wrong ID
|
|
||||||
assert decode_command([0xF0, 0x7D, 0xF7]) is None # empty payload
|
|
||||||
print("PASS: decode_command rejects invalid input")
|
|
||||||
|
|
||||||
|
|
||||||
def test_sysex_id():
|
|
||||||
"""Verify SYSEX_ID is the correct non-commercial experimental ID."""
|
|
||||||
assert SYSEX_ID == 0x7D
|
|
||||||
print("PASS: SYSEX_ID == 0x7D")
|
|
||||||
|
|
||||||
|
|
||||||
def test_miditransport_list_ports():
|
|
||||||
"""Test that MidiTransport.list_ports() works without crashing."""
|
|
||||||
ports = MidiTransport.list_ports()
|
|
||||||
assert "inputs" in ports
|
|
||||||
assert "outputs" in ports
|
|
||||||
assert isinstance(ports["inputs"], list)
|
|
||||||
assert isinstance(ports["outputs"], list)
|
|
||||||
print(f"PASS: list_ports — inputs={ports['inputs']}, outputs={ports['outputs']}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_sysex_protocol_complete_roundtrip():
|
|
||||||
"""Full end-to-end: encode → send模拟 → receive → decode."""
|
|
||||||
# Simulate a complete conversation
|
|
||||||
commands = [
|
|
||||||
("ping", {"ts": 1}),
|
|
||||||
("set_tempo", {"tempo": 92.0}),
|
|
||||||
("select_channel", {"channel_index": 3}),
|
|
||||||
("note_on", {"channel_index": 0, "note": 60, "velocity": 100}),
|
|
||||||
("stop_all_notes", {}),
|
|
||||||
]
|
|
||||||
for cmd, params in commands:
|
|
||||||
encoded = encode_command(cmd, params)
|
|
||||||
# Verify format
|
|
||||||
assert encoded[0] == 0xF0
|
|
||||||
assert encoded[1] == SYSEX_ID
|
|
||||||
assert encoded[-1] == 0xF7
|
|
||||||
# Verify decode
|
|
||||||
decoded = decode_command(encoded)
|
|
||||||
assert decoded is not None
|
|
||||||
assert decoded["cmd"] == cmd
|
|
||||||
assert decoded["params"] == params
|
|
||||||
print("PASS: complete command roundtrip")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("FL Studio MCP — Integration Tests")
|
|
||||||
print("==================================\n")
|
|
||||||
|
|
||||||
test_sysex_id()
|
|
||||||
test_nibble_encode_decode_roundtrip()
|
|
||||||
test_encode_command_format()
|
|
||||||
test_decode_command_invalid()
|
|
||||||
test_miditransport_list_ports()
|
|
||||||
test_sysex_protocol_complete_roundtrip()
|
|
||||||
|
|
||||||
print("\nAll tests passed!")
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
"""
|
|
||||||
Phase 0: SysEx Loopback Validation Test.
|
|
||||||
|
|
||||||
Tests whether MIDI SysEx messages can travel through the Windows MIDI Services
|
|
||||||
loopback port "FL_MCP" from the MCP server to the FL Studio controller script.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python test_sysex_loopback.py
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
- FL Studio running with FL_MCP controller script loaded
|
|
||||||
- Windows MIDI Services loopback ports "FL_MCP 0" (input) and "FL_MCP 1" (output)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
try:
|
|
||||||
import mido
|
|
||||||
except ImportError:
|
|
||||||
print("ERROR: mido not installed. Run: pip install mido")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def send_sysex_raw(output, data):
|
|
||||||
"""Send raw SysEx bytes via mido."""
|
|
||||||
msg = mido.Message("sysex", data=bytes(data))
|
|
||||||
output.send(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def test_simple_sysex():
|
|
||||||
"""Test 1: Send F0 7D 48 69 F7 ("Hi") and expect it back."""
|
|
||||||
print("\n=== Test 1: Simple SysEx Echo ===")
|
|
||||||
output_name = None
|
|
||||||
input_name = None
|
|
||||||
|
|
||||||
for name in mido.get_output_names():
|
|
||||||
if "FL_MCP" in name and "1" in name:
|
|
||||||
output_name = name
|
|
||||||
break
|
|
||||||
for name in mido.get_input_names():
|
|
||||||
if "FL_MCP" in name and "0" in name:
|
|
||||||
input_name = name
|
|
||||||
break
|
|
||||||
|
|
||||||
if not output_name:
|
|
||||||
print("FAIL: FL_MCP output port (FL_MCP 1) not found")
|
|
||||||
print("Available outputs:", mido.get_output_names())
|
|
||||||
return False
|
|
||||||
if not input_name:
|
|
||||||
print("FAIL: FL_MCP input port (FL_MCP 0) not found")
|
|
||||||
print("Available inputs:", mido.get_input_names())
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"Output: {output_name}")
|
|
||||||
print(f"Input: {input_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = mido.open_output(output_name)
|
|
||||||
input_port = mido.open_input(input_name)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"FAIL: Cannot open ports: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Send "Hi" ping: F0 7D 48 69 F7
|
|
||||||
# (0x48='H', 0x69='i')
|
|
||||||
ping_data = [0xF0, 0x7D, 0x48, 0x69, 0xF7]
|
|
||||||
print(f"Sending: {' '.join(f'{b:02X}' for b in ping_data)}")
|
|
||||||
send_sysex_raw(output, ping_data[1:-1]) # mido adds F0/F7
|
|
||||||
|
|
||||||
print("Waiting 5s for echo...")
|
|
||||||
timeout = 5.0
|
|
||||||
start = time.time()
|
|
||||||
received = None
|
|
||||||
while time.time() - start < timeout:
|
|
||||||
msg = input_port.receive(timeout=0.1)
|
|
||||||
if msg and msg.type == "sysex":
|
|
||||||
received = list(msg.bytes())
|
|
||||||
break
|
|
||||||
|
|
||||||
output.close()
|
|
||||||
input_port.close()
|
|
||||||
|
|
||||||
if received:
|
|
||||||
print(f"Received: {' '.join(f'{b:02X}' for b in received)}")
|
|
||||||
if received[0] == 0xF0 and received[-1] == 0xF7 and received[1] == 0x7D:
|
|
||||||
print("PASS: Simple SysEx loopback works!")
|
|
||||||
return True
|
|
||||||
print(f"UNKNOWN: Received but unexpected bytes: {received}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
print("FAIL: No message received within timeout")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_command():
|
|
||||||
"""Test 2: Send a proper JSON command via nibble encoding."""
|
|
||||||
print("\n=== Test 2: JSON Command SysEx ===")
|
|
||||||
import json
|
|
||||||
|
|
||||||
output_name = None
|
|
||||||
input_name = None
|
|
||||||
for name in mido.get_output_names():
|
|
||||||
if "FL_MCP" in name and "1" in name:
|
|
||||||
output_name = name
|
|
||||||
break
|
|
||||||
for name in mido.get_input_names():
|
|
||||||
if "FL_MCP" in name and "0" in name:
|
|
||||||
input_name = name
|
|
||||||
break
|
|
||||||
|
|
||||||
if not output_name or not input_name:
|
|
||||||
print("SKIP: FL_MCP ports not available for JSON test")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = mido.open_output(output_name)
|
|
||||||
input_port = mido.open_input(input_name)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"SKIP: Cannot open ports: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Encode ping command: {"cmd":"ping","params":{"ts":1}}
|
|
||||||
payload = json.dumps({"cmd": "ping", "params": {"ts": 1}}).encode("utf-8")
|
|
||||||
print(f"JSON payload: {payload}")
|
|
||||||
|
|
||||||
# Nibble encode
|
|
||||||
nibbles = []
|
|
||||||
for byte in payload:
|
|
||||||
nibbles.append((byte >> 4) & 0x0F)
|
|
||||||
nibbles.append(byte & 0x0F)
|
|
||||||
|
|
||||||
sysex_data = [0xF0, 0x7D] + nibbles + [0xF7]
|
|
||||||
print(f"SysEx: {' '.join(f'{b:02X}' for b in sysex_data)}")
|
|
||||||
|
|
||||||
send_sysex_raw(output, sysex_data[1:-1])
|
|
||||||
print("Waiting 5s for response...")
|
|
||||||
|
|
||||||
timeout = 5.0
|
|
||||||
start = time.time()
|
|
||||||
received = None
|
|
||||||
while time.time() - start < timeout:
|
|
||||||
msg = input_port.receive(timeout=0.1)
|
|
||||||
if msg and msg.type == "sysex":
|
|
||||||
received = list(msg.bytes())
|
|
||||||
break
|
|
||||||
|
|
||||||
output.close()
|
|
||||||
input_port.close()
|
|
||||||
|
|
||||||
if received:
|
|
||||||
print(f"Received: {' '.join(f'{b:02X}' for b in received)}")
|
|
||||||
# Decode nibbles
|
|
||||||
if received[1] == 0x7D:
|
|
||||||
nibble_data = received[2:-1]
|
|
||||||
result = bytearray()
|
|
||||||
for i in range(0, len(nibble_data), 2):
|
|
||||||
if i + 1 < len(nibble_data):
|
|
||||||
result.append(((nibble_data[i] & 0x0F) << 4) | (nibble_data[i + 1] & 0x0F))
|
|
||||||
decoded = result.decode("utf-8")
|
|
||||||
print(f"Decoded JSON: {decoded}")
|
|
||||||
print("PASS: JSON command roundtrip works!")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print("FAIL: No response within timeout")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def list_ports():
|
|
||||||
"""List all available MIDI ports."""
|
|
||||||
print("\n=== Available MIDI Ports ===")
|
|
||||||
print("Inputs:")
|
|
||||||
for p in mido.get_input_names():
|
|
||||||
print(f" {p}")
|
|
||||||
print("Outputs:")
|
|
||||||
for p in mido.get_output_names():
|
|
||||||
print(f" {p}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("FL Studio MCP — SysEx Loopback Validation")
|
|
||||||
print("=========================================")
|
|
||||||
list_ports()
|
|
||||||
|
|
||||||
test1 = test_simple_sysex()
|
|
||||||
test2 = test_json_command()
|
|
||||||
|
|
||||||
print("\n=== Summary ===")
|
|
||||||
print(f"Simple SysEx: {'PASS' if test1 else 'FAIL'}")
|
|
||||||
print(f"JSON Command: {'PASS' if test2 else 'FAIL'}")
|
|
||||||
|
|
||||||
if test1 and test2:
|
|
||||||
print("\nSysEx loopback is functional. Phase 1-4 can proceed.")
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print("\nSysEx loopback FAILED. Phase 1-4 ABORTED — pivot required.")
|
|
||||||
sys.exit(1)
|
|
||||||
Binary file not shown.
@@ -5,3 +5,4 @@ scipy>=1.11.0
|
|||||||
soundfile>=0.12.0
|
soundfile>=0.12.0
|
||||||
mido>=1.3.0
|
mido>=1.3.0
|
||||||
fastmcp>=0.1.0
|
fastmcp>=0.1.0
|
||||||
|
rpp>=0.5
|
||||||
|
|||||||
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Batch FLP generator — produces 50 unique reggaeton FLP+JSON pairs.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/batch_generate.py [--count 50] [--out-dir output/batch]
|
|
||||||
|
|
||||||
Output structure:
|
|
||||||
output/batch_{timestamp}/
|
|
||||||
reggaeton_000_95bpm_Am_i-VII-VI-VII.json
|
|
||||||
reggaeton_000_95bpm_Am_i-VII-VI-VII.flp
|
|
||||||
reggaeton_001_90bpm_Dm_i-iv-VII-III.json
|
|
||||||
...
|
|
||||||
manifest.json ← list of all generated songs with metadata
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
||||||
|
|
||||||
from src.composer.variation import generate_batch
|
|
||||||
from src.flp_builder.builder import FLPBuilder
|
|
||||||
from src.flp_builder.schema import SongDefinition
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Filename helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_UNSAFE_RE = re.compile(r'[^\w\-]')
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_filename(s: str) -> str:
|
|
||||||
"""Replace unsafe filename chars with _."""
|
|
||||||
return _UNSAFE_RE.sub('_', s)
|
|
||||||
|
|
||||||
|
|
||||||
def make_filename(idx: int, song: SongDefinition) -> str:
|
|
||||||
"""Build stem like ``reggaeton_000_95bpm_Am_i_VII_VI_VII`` (no extension)."""
|
|
||||||
prog_safe = sanitize_filename(song.progression_name)
|
|
||||||
return f"reggaeton_{idx:03d}_{song.meta.bpm}bpm_{song.meta.key}_{prog_safe}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Manifest
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_manifest(songs: list[SongDefinition], filenames: list[str]) -> dict:
|
|
||||||
"""Build manifest dict with per-song metadata."""
|
|
||||||
entries = []
|
|
||||||
for idx, (song, stem) in enumerate(zip(songs, filenames)):
|
|
||||||
bar_count = int(max(item.bar + item.bars for item in song.items))
|
|
||||||
entries.append({
|
|
||||||
"idx": idx,
|
|
||||||
"filename": stem,
|
|
||||||
"bpm": song.meta.bpm,
|
|
||||||
"key": song.meta.key,
|
|
||||||
"progression": song.progression_name,
|
|
||||||
"title": song.meta.title,
|
|
||||||
"bars": bar_count,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
"generated_at": datetime.now().isoformat(),
|
|
||||||
"count": len(songs),
|
|
||||||
"songs": entries,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Main
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Batch FLP generator")
|
|
||||||
parser.add_argument("--count", type=int, default=50,
|
|
||||||
help="Number of songs to generate (default: 50)")
|
|
||||||
parser.add_argument("--out-dir", default="",
|
|
||||||
help="Output directory (default: output/batch_{timestamp})")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
out_dir = Path(args.out_dir) if args.out_dir else Path("output") / f"batch_{timestamp}"
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
print(f"Generating {args.count} songs -> {out_dir}")
|
|
||||||
|
|
||||||
songs = generate_batch(args.count)
|
|
||||||
builder = FLPBuilder()
|
|
||||||
filenames: list[str] = []
|
|
||||||
|
|
||||||
for idx, song in enumerate(songs):
|
|
||||||
stem = make_filename(idx, song)
|
|
||||||
filenames.append(stem)
|
|
||||||
|
|
||||||
# Write JSON
|
|
||||||
json_path = out_dir / f"{stem}.json"
|
|
||||||
json_path.write_text(song.to_json(), encoding="utf-8")
|
|
||||||
|
|
||||||
# Write FLP
|
|
||||||
flp_path = out_dir / f"{stem}.flp"
|
|
||||||
flp_bytes = builder.build(song)
|
|
||||||
flp_path.write_bytes(flp_bytes)
|
|
||||||
|
|
||||||
bar_count = int(max(item.bar + item.bars for item in song.items))
|
|
||||||
print(f" [{idx+1:>3}/{args.count}] {stem}.flp {len(flp_bytes):>9,}b {bar_count}bars")
|
|
||||||
|
|
||||||
# Write manifest
|
|
||||||
manifest = build_manifest(songs, filenames)
|
|
||||||
(out_dir / "manifest.json").write_text(
|
|
||||||
json.dumps(manifest, indent=2), encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
total_size = sum((out_dir / f"{f}.flp").stat().st_size for f in filenames)
|
|
||||||
print(f"\nDone. {args.count} FLPs in {out_dir}")
|
|
||||||
print(f" Total size: {total_size:,} bytes")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
160
scripts/build.py
160
scripts/build.py
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Build an FL Studio project from a composition plan JSON."""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
|
|
||||||
from src.flp_builder.project import FLPProject, Note
|
|
||||||
from src.flp_builder.writer import FLPWriter
|
|
||||||
|
|
||||||
PLUGIN_NAME_MAP = {
|
|
||||||
"Serum 2": "Serum2VST3",
|
|
||||||
"Omnisphere": "Omnisphere",
|
|
||||||
"Kontakt 7": "Kontakt 7",
|
|
||||||
"Diva": "Diva",
|
|
||||||
"Electra": "Electra",
|
|
||||||
"Pigments": "Pigments",
|
|
||||||
"ravity(S)": "ravity(S)",
|
|
||||||
"FL Keys": "FL Keys",
|
|
||||||
"FPC": "FPC",
|
|
||||||
"FLEX": "FLEX",
|
|
||||||
"Sytrus": "Sytrus",
|
|
||||||
"Harmor": "Harmor",
|
|
||||||
"3x Osc": "3x Osc",
|
|
||||||
"DirectWave": "DirectWave",
|
|
||||||
"Fruity DrumSynth Live": "Fruity DrumSynth Live",
|
|
||||||
"Transistor Bass": "Transistor Bass",
|
|
||||||
"Sakura": "Sakura",
|
|
||||||
"Sawer": "Sawer",
|
|
||||||
"Toxic Biohazard": "Toxic Biohazard",
|
|
||||||
"Harmless": "Harmless",
|
|
||||||
"GMS": "GMS",
|
|
||||||
"Minisynth": "Minisynth",
|
|
||||||
"Morphine": "Morphine",
|
|
||||||
"Soundfont Player": "Soundfont Player",
|
|
||||||
}
|
|
||||||
|
|
||||||
OUTPUT_DIR = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) / "output"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plugin(preferred_list):
|
|
||||||
for name in preferred_list:
|
|
||||||
if name in PLUGIN_NAME_MAP:
|
|
||||||
internal = PLUGIN_NAME_MAP[name]
|
|
||||||
is_vst = name in [
|
|
||||||
"Serum 2", "Omnisphere", "Kontakt 7", "Diva",
|
|
||||||
"Electra", "Pigments", "ravity(S)",
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
"internal_name": "Fruity Wrapper" if is_vst else internal,
|
|
||||||
"display_name": name,
|
|
||||||
"is_vst": is_vst,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"internal_name": "MIDI Out",
|
|
||||||
"display_name": "MIDI Out",
|
|
||||||
"is_vst": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_project(composition: dict) -> FLPProject:
|
|
||||||
meta = composition["meta"]
|
|
||||||
tracks = composition["tracks"]
|
|
||||||
|
|
||||||
project = FLPProject(
|
|
||||||
tempo=meta["bpm"],
|
|
||||||
title=meta.get("title", f"{meta.get('genre', 'Untitled')} - {meta.get('key', 'C')}"),
|
|
||||||
genre=meta.get("genre", ""),
|
|
||||||
fl_version="24.7.1.73",
|
|
||||||
ppq=meta.get("ppq", 96),
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_map = {}
|
|
||||||
for i, track in enumerate(tracks):
|
|
||||||
role = track["role"]
|
|
||||||
plugin_info = resolve_plugin(track.get("preferred_plugins", []))
|
|
||||||
ch = project.add_channel(
|
|
||||||
name=f"{role}_{plugin_info['display_name']}",
|
|
||||||
plugin_internal_name=plugin_info["internal_name"],
|
|
||||||
plugin_display_name=plugin_info["display_name"],
|
|
||||||
mixer_track=track.get("mixer_slot", i),
|
|
||||||
channel_type=2,
|
|
||||||
)
|
|
||||||
channel_map[role] = ch.index
|
|
||||||
|
|
||||||
bars = meta.get("bars", 8)
|
|
||||||
ppq = meta.get("ppq", 96)
|
|
||||||
beats_per_chord = meta.get("beats_per_chord", 4)
|
|
||||||
|
|
||||||
for section_idx, track in enumerate(tracks):
|
|
||||||
role = track["role"]
|
|
||||||
ch_idx = channel_map.get(role, 0)
|
|
||||||
raw_notes = track.get("notes", [])
|
|
||||||
|
|
||||||
if not raw_notes:
|
|
||||||
continue
|
|
||||||
|
|
||||||
pat = project.add_pattern(name=f"{role}")
|
|
||||||
for n in raw_notes:
|
|
||||||
note = Note(
|
|
||||||
position=n["position"],
|
|
||||||
length=n["length"],
|
|
||||||
key=n.get("key", 60),
|
|
||||||
velocity=n.get("velocity", 100),
|
|
||||||
pan=n.get("pan", 0),
|
|
||||||
mod_x=n.get("mod_x", 0),
|
|
||||||
mod_y=n.get("mod_y", 0),
|
|
||||||
)
|
|
||||||
pat.add_note(ch_idx, note)
|
|
||||||
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Build FL Studio project from composition plan")
|
|
||||||
parser.add_argument("plan", help="Path to composition plan JSON")
|
|
||||||
parser.add_argument("--output", "-o", help="Output .flp file path", default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
with open(args.plan, "r", encoding="utf-8") as f:
|
|
||||||
composition = json.load(f)
|
|
||||||
|
|
||||||
project = build_project(composition)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
output_path = args.output
|
|
||||||
else:
|
|
||||||
genre = composition["meta"].get("genre", "track")
|
|
||||||
key = composition["meta"].get("key", "C")
|
|
||||||
bpm = composition["meta"].get("bpm", 140)
|
|
||||||
output_path = str(OUTPUT_DIR / f"{genre}_{key}_{bpm}bpm.flp")
|
|
||||||
|
|
||||||
writer = FLPWriter(project)
|
|
||||||
writer.write(output_path)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"status": "ok",
|
|
||||||
"output": output_path,
|
|
||||||
"tempo": project.tempo,
|
|
||||||
"channels": len(project.channels),
|
|
||||||
"patterns": len(project.patterns),
|
|
||||||
"channel_names": [ch.name for ch in project.channels],
|
|
||||||
"pattern_names": [p.name for p in project.patterns],
|
|
||||||
"total_notes": sum(
|
|
||||||
len(notes)
|
|
||||||
for pat in project.patterns
|
|
||||||
for notes in pat.notes.values()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
"""
|
|
||||||
Build a COMPLETE reggaeton FLP with drums + melodic MIDI patterns.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Load 20 sampler channels from reference FLP (ChannelSkeletonLoader)
|
|
||||||
2. Melodic MIDI notes go on existing EMPTY channels (3, 4, 8, 17)
|
|
||||||
which are empty samplers — user assigns VST plugins in FL Studio.
|
|
||||||
3. Build 14 patterns with drum generators + inline melodic generators
|
|
||||||
4. Build 36-bar arrangement (~1:31 at 95 BPM)
|
|
||||||
5. Assemble identically to proven v15 builder — 20 channels, no VST hacks.
|
|
||||||
|
|
||||||
Output: output/reggaeton_completo.flp
|
|
||||||
"""
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# ── Paths ──────────────────────────────────────────────────────────────────────
|
|
||||||
BASE = r"C:\Users\Administrator\Documents\fl_control"
|
|
||||||
SAMPLES_DIR = os.path.join(BASE, "output", "samples")
|
|
||||||
CH11_TMPL = os.path.join(BASE, "output", "ch11_kick_template.bin")
|
|
||||||
REF_FLP = os.path.join(BASE, r"my space ryt\my space ryt.flp")
|
|
||||||
FLP_OUT = os.path.join(BASE, "output", "reggaeton_completo.flp")
|
|
||||||
|
|
||||||
sys.path.insert(0, BASE)
|
|
||||||
|
|
||||||
from src.flp_builder.events import (
|
|
||||||
EventID,
|
|
||||||
encode_text_event,
|
|
||||||
encode_word_event,
|
|
||||||
encode_data_event,
|
|
||||||
encode_byte_event,
|
|
||||||
encode_notes_block,
|
|
||||||
)
|
|
||||||
from src.flp_builder.skeleton import ChannelSkeletonLoader
|
|
||||||
from src.flp_builder.arrangement import (
|
|
||||||
ArrangementItem,
|
|
||||||
build_arrangement_section,
|
|
||||||
build_track_data_template,
|
|
||||||
)
|
|
||||||
from src.composer.rhythm import get_notes
|
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
||||||
BPM = 95
|
|
||||||
PPQ = 96
|
|
||||||
|
|
||||||
# Channel indices — drums (from rhythm.py)
|
|
||||||
CH_P1 = 10; CH_K = 11; CH_S = 12; CH_R = 13
|
|
||||||
CH_P2 = 14; CH_H = 15; CH_CL = 16
|
|
||||||
|
|
||||||
# Channel indices — melodic (reuse empty sampler channels from reference)
|
|
||||||
# Ch 3, 4, 8, 17 are empty samplers (no sample loaded, cloned from ch11 tmpl)
|
|
||||||
# MIDI notes go here — user assigns VSTs manually in FL Studio
|
|
||||||
CH_808 = 3
|
|
||||||
CH_PIANO = 4
|
|
||||||
CH_LEAD = 8
|
|
||||||
CH_PAD = 17
|
|
||||||
|
|
||||||
|
|
||||||
# ── Chord Progression: Am → G → F → G (each chord = 2 bars = 8 beats) ──────
|
|
||||||
PROGRESSION = [
|
|
||||||
{
|
|
||||||
"name": "Am",
|
|
||||||
"bass_root": 45, # A2
|
|
||||||
"chord": [57, 60, 64], # A3, C4, E4
|
|
||||||
"pad": [45, 48, 52], # A2, C3, E3
|
|
||||||
"lead_root": 69, # A4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "G",
|
|
||||||
"bass_root": 43, # G2
|
|
||||||
"chord": [55, 59, 62], # G3, B3, D4
|
|
||||||
"pad": [43, 47, 50], # G2, B2, D3
|
|
||||||
"lead_root": 67, # G4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "F",
|
|
||||||
"bass_root": 41, # F2
|
|
||||||
"chord": [53, 57, 60], # F3, A3, C4
|
|
||||||
"pad": [41, 45, 48], # F2, A2, C3
|
|
||||||
"lead_root": 65, # F4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "G",
|
|
||||||
"bass_root": 43,
|
|
||||||
"chord": [55, 59, 62],
|
|
||||||
"pad": [43, 47, 50],
|
|
||||||
"lead_root": 67,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
BEATS_PER_CHORD = 8 # 2 bars per chord
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# MELODIC GENERATORS (inline)
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _note(pos, length, key, vel):
|
|
||||||
return {"pos": pos, "len": length, "key": key, "vel": vel}
|
|
||||||
|
|
||||||
|
|
||||||
def bass_808_notes(bars):
|
|
||||||
"""808 bass following root notes. Pattern per chord (2 bars):
|
|
||||||
Beat 0: root vel110 dur3 | Beat 3.5: root vel90 dur1.5
|
|
||||||
Beat 5: root vel100 dur2 | Beat 7.5: root vel85 dur0.5
|
|
||||||
"""
|
|
||||||
notes = []
|
|
||||||
total_beats = bars * 4
|
|
||||||
chords_needed = total_beats // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords_needed):
|
|
||||||
ch = PROGRESSION[ci % len(PROGRESSION)]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
root = ch["bass_root"]
|
|
||||||
notes.append(_note(base + 0.0, 3.0, root, 110))
|
|
||||||
notes.append(_note(base + 3.5, 1.5, root, 90))
|
|
||||||
notes.append(_note(base + 5.0, 2.0, root, 100))
|
|
||||||
notes.append(_note(base + 7.5, 0.5, root, 85))
|
|
||||||
return {CH_808: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def piano_stabs_notes(bars):
|
|
||||||
"""Offbeat piano stabs: beats 1.5, 2.5, 3.5, 5.5, 6.5, 7.5 per chord.
|
|
||||||
3-note triads, vel 80-90."""
|
|
||||||
notes = []
|
|
||||||
total_beats = bars * 4
|
|
||||||
chords_needed = total_beats // BEATS_PER_CHORD
|
|
||||||
stab_positions = [1.5, 2.5, 3.5, 5.5, 6.5, 7.5]
|
|
||||||
for ci in range(chords_needed):
|
|
||||||
ch = PROGRESSION[ci % len(PROGRESSION)]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for sp in stab_positions:
|
|
||||||
vel = 80 + (hash((ci, sp)) % 11)
|
|
||||||
for pitch in ch["chord"]:
|
|
||||||
notes.append(_note(base + sp, 0.15, pitch, vel))
|
|
||||||
return {CH_PIANO: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def piano_sparse_notes(bars):
|
|
||||||
"""Sparse piano for intro/breakdown: beats 2.5 and 6.5 only, vel 65-70."""
|
|
||||||
notes = []
|
|
||||||
total_beats = bars * 4
|
|
||||||
chords_needed = total_beats // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords_needed):
|
|
||||||
ch = PROGRESSION[ci % len(PROGRESSION)]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for sp in [2.5, 6.5]:
|
|
||||||
vel = 65 + (hash((ci, sp)) % 6)
|
|
||||||
for pitch in ch["chord"]:
|
|
||||||
notes.append(_note(base + sp, 0.15, pitch, vel))
|
|
||||||
return {CH_PIANO: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def lead_hook_notes(bars):
|
|
||||||
"""Melodic hook emphasizing chord tones per 2-bar cycle."""
|
|
||||||
notes = []
|
|
||||||
total_beats = bars * 4
|
|
||||||
chords_needed = total_beats // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords_needed):
|
|
||||||
ch = PROGRESSION[ci % len(PROGRESSION)]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
lr = ch["lead_root"]
|
|
||||||
notes.append(_note(base + 0.0, 1.0, ch["chord"][0], 100))
|
|
||||||
notes.append(_note(base + 1.0, 0.5, ch["chord"][2], 95))
|
|
||||||
notes.append(_note(base + 2.0, 0.75, ch["chord"][1], 90))
|
|
||||||
notes.append(_note(base + 3.5, 0.25, lr, 85))
|
|
||||||
notes.append(_note(base + 5.0, 0.5, ch["chord"][2], 95))
|
|
||||||
notes.append(_note(base + 5.5, 1.0, ch["chord"][0], 100))
|
|
||||||
notes.append(_note(base + 6.5, 0.5, lr + 2, 80))
|
|
||||||
return {CH_LEAD: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def pad_sustained_notes(bars):
|
|
||||||
"""Long sustained pad chords. 3 notes per chord, vel 65, dur 7.5 beats."""
|
|
||||||
notes = []
|
|
||||||
total_beats = bars * 4
|
|
||||||
chords_needed = total_beats // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords_needed):
|
|
||||||
ch = PROGRESSION[ci % len(PROGRESSION)]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for pitch in ch["pad"]:
|
|
||||||
notes.append(_note(base + 0.0, 7.5, pitch, 65))
|
|
||||||
return {CH_PAD: notes}
|
|
||||||
|
|
||||||
|
|
||||||
MELODIC_GENERATORS = {
|
|
||||||
"bass_808_notes": bass_808_notes,
|
|
||||||
"piano_stabs_notes": piano_stabs_notes,
|
|
||||||
"piano_sparse_notes": piano_sparse_notes,
|
|
||||||
"lead_hook_notes": lead_hook_notes,
|
|
||||||
"pad_sustained_notes": pad_sustained_notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PATTERN DEFINITIONS
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
PATTERNS = [
|
|
||||||
{"id": 1, "name": "Kick Main", "generator": "kick_main_notes", "bars": 8},
|
|
||||||
{"id": 2, "name": "Kick Sparse", "generator": "kick_sparse_notes", "bars": 8},
|
|
||||||
{"id": 3, "name": "Snare Verse", "generator": "snare_verse_notes", "bars": 8},
|
|
||||||
{"id": 4, "name": "Hihat 16th", "generator": "hihat_16th_notes", "bars": 8},
|
|
||||||
{"id": 5, "name": "Hihat 8th", "generator": "hihat_8th_notes", "bars": 8},
|
|
||||||
{"id": 6, "name": "Clap 24", "generator": "clap_24_notes", "bars": 8},
|
|
||||||
{"id": 7, "name": "Rim Build", "generator": "rim_build_notes", "bars": 4},
|
|
||||||
{"id": 8, "name": "Perc Combo", "generator": "perc_combo_notes", "bars": 8},
|
|
||||||
{"id": 9, "name": "Kick Outro", "generator": "kick_outro_notes", "bars": 8},
|
|
||||||
{"id": 10, "name": "808 Bass", "generator": "bass_808_notes", "bars": 8, "melodic": True},
|
|
||||||
{"id": 11, "name": "Piano Stabs", "generator": "piano_stabs_notes", "bars": 8, "melodic": True},
|
|
||||||
{"id": 12, "name": "Piano Sparse", "generator": "piano_sparse_notes","bars": 8, "melodic": True},
|
|
||||||
{"id": 13, "name": "Lead Hook", "generator": "lead_hook_notes", "bars": 8, "melodic": True},
|
|
||||||
{"id": 14, "name": "Pad Sustained","generator": "pad_sustained_notes","bars": 8, "melodic": True},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# ARRANGEMENT (36 bars = ~1:31 at 95 BPM)
|
|
||||||
# 9 arrangement tracks
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
ARRANGEMENT_ITEMS = [
|
|
||||||
# INTRO (0-4)
|
|
||||||
{"pattern": 2, "bar": 0, "bars": 4, "track": 0},
|
|
||||||
{"pattern": 5, "bar": 0, "bars": 4, "track": 2},
|
|
||||||
{"pattern": 14, "bar": 0, "bars": 4, "track": 8},
|
|
||||||
{"pattern": 12, "bar": 0, "bars": 4, "track": 6},
|
|
||||||
# VERSE (4-12)
|
|
||||||
{"pattern": 1, "bar": 4, "bars": 8, "track": 0},
|
|
||||||
{"pattern": 3, "bar": 4, "bars": 8, "track": 1},
|
|
||||||
{"pattern": 4, "bar": 4, "bars": 8, "track": 2},
|
|
||||||
{"pattern": 8, "bar": 4, "bars": 8, "track": 4},
|
|
||||||
{"pattern": 10, "bar": 4, "bars": 8, "track": 5},
|
|
||||||
{"pattern": 11, "bar": 4, "bars": 8, "track": 6},
|
|
||||||
{"pattern": 14, "bar": 4, "bars": 8, "track": 8},
|
|
||||||
# PRE-CHORUS (12-16)
|
|
||||||
{"pattern": 1, "bar": 12, "bars": 4, "track": 0},
|
|
||||||
{"pattern": 3, "bar": 12, "bars": 4, "track": 1},
|
|
||||||
{"pattern": 4, "bar": 12, "bars": 4, "track": 2},
|
|
||||||
{"pattern": 7, "bar": 12, "bars": 4, "track": 3},
|
|
||||||
{"pattern": 8, "bar": 12, "bars": 4, "track": 4},
|
|
||||||
{"pattern": 10, "bar": 12, "bars": 4, "track": 5},
|
|
||||||
{"pattern": 11, "bar": 12, "bars": 4, "track": 6},
|
|
||||||
{"pattern": 14, "bar": 12, "bars": 4, "track": 8},
|
|
||||||
# CHORUS (16-24)
|
|
||||||
{"pattern": 1, "bar": 16, "bars": 8, "track": 0},
|
|
||||||
{"pattern": 3, "bar": 16, "bars": 8, "track": 1},
|
|
||||||
{"pattern": 4, "bar": 16, "bars": 8, "track": 2},
|
|
||||||
{"pattern": 6, "bar": 16, "bars": 8, "track": 3},
|
|
||||||
{"pattern": 8, "bar": 16, "bars": 8, "track": 4},
|
|
||||||
{"pattern": 10, "bar": 16, "bars": 8, "track": 5},
|
|
||||||
{"pattern": 11, "bar": 16, "bars": 8, "track": 6},
|
|
||||||
{"pattern": 13, "bar": 16, "bars": 8, "track": 7},
|
|
||||||
{"pattern": 14, "bar": 16, "bars": 8, "track": 8},
|
|
||||||
# BREAKDOWN (24-28)
|
|
||||||
{"pattern": 5, "bar": 24, "bars": 4, "track": 2},
|
|
||||||
{"pattern": 14, "bar": 24, "bars": 4, "track": 8},
|
|
||||||
{"pattern": 12, "bar": 24, "bars": 4, "track": 6},
|
|
||||||
# OUTRO (28-36)
|
|
||||||
{"pattern": 9, "bar": 28, "bars": 8, "track": 0},
|
|
||||||
{"pattern": 3, "bar": 28, "bars": 8, "track": 1},
|
|
||||||
{"pattern": 4, "bar": 28, "bars": 8, "track": 2},
|
|
||||||
{"pattern": 14, "bar": 28, "bars": 8, "track": 8},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# HEADER BUILDER
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _read_ev(data, pos):
|
|
||||||
s = pos
|
|
||||||
ib = data[pos]
|
|
||||||
pos += 1
|
|
||||||
if ib < 64:
|
|
||||||
return pos + 1, s, ib, data[s + 1], "byte"
|
|
||||||
elif ib < 128:
|
|
||||||
return pos + 2, s, ib, struct.unpack("<H", data[pos:pos + 2])[0], "word"
|
|
||||||
elif ib < 192:
|
|
||||||
return pos + 4, s, ib, struct.unpack("<I", data[pos:pos + 4])[0], "dword"
|
|
||||||
else:
|
|
||||||
sz = 0; sh = 0
|
|
||||||
while True:
|
|
||||||
b = data[pos]; pos += 1
|
|
||||||
sz |= (b & 0x7F) << sh; sh += 7
|
|
||||||
if not (b & 0x80): break
|
|
||||||
return pos + sz, s, ib, data[pos:pos + sz], "data"
|
|
||||||
|
|
||||||
|
|
||||||
def build_header(ref_bytes):
|
|
||||||
"""Extract header events from reference FLP, patch BPM to 95."""
|
|
||||||
pos = 22
|
|
||||||
first_pat = None
|
|
||||||
while pos < len(ref_bytes):
|
|
||||||
np, st, ib, val, vt = _read_ev(ref_bytes, pos)
|
|
||||||
if ib == EventID.PatNew:
|
|
||||||
first_pat = st
|
|
||||||
break
|
|
||||||
pos = np
|
|
||||||
|
|
||||||
if first_pat is None:
|
|
||||||
raise ValueError("No PatNew event found in reference FLP")
|
|
||||||
|
|
||||||
header = bytearray(ref_bytes[22:first_pat])
|
|
||||||
|
|
||||||
# Patch BPM
|
|
||||||
p = 0
|
|
||||||
while p < len(header):
|
|
||||||
np, _, ib, val, vt = _read_ev(bytes(header), p)
|
|
||||||
if ib == EventID.Tempo:
|
|
||||||
struct.pack_into("<I", header, p + 1, BPM * 1000)
|
|
||||||
break
|
|
||||||
p = np
|
|
||||||
|
|
||||||
return bytes(header)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PATTERN BUILDER
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _convert_rhythm_notes(notes):
|
|
||||||
return [
|
|
||||||
{"position": n["pos"], "length": n["len"], "key": n["key"], "velocity": n["vel"]}
|
|
||||||
for n in notes
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_all_patterns():
|
|
||||||
buf = bytearray()
|
|
||||||
for pat_def in PATTERNS:
|
|
||||||
pat_id = pat_def["id"]
|
|
||||||
gen_name = pat_def["generator"]
|
|
||||||
bars = pat_def["bars"]
|
|
||||||
is_melodic = pat_def.get("melodic", False)
|
|
||||||
|
|
||||||
buf += encode_word_event(EventID.PatNew, pat_id - 1)
|
|
||||||
buf += encode_text_event(EventID.PatName, pat_def["name"])
|
|
||||||
|
|
||||||
if is_melodic:
|
|
||||||
notes_by_channel = MELODIC_GENERATORS[gen_name](bars)
|
|
||||||
else:
|
|
||||||
notes_by_channel = get_notes(gen_name, bars)
|
|
||||||
|
|
||||||
for ch_idx, raw_notes in notes_by_channel.items():
|
|
||||||
if not raw_notes:
|
|
||||||
continue
|
|
||||||
converted = _convert_rhythm_notes(raw_notes)
|
|
||||||
buf += encode_data_event(
|
|
||||||
EventID.PatNotes,
|
|
||||||
encode_notes_block(ch_idx, converted, PPQ),
|
|
||||||
)
|
|
||||||
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# MAIN BUILD — identical assembly to proven v15 builder
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def build_complete_reggaeton():
|
|
||||||
print("=" * 60)
|
|
||||||
print("Building COMPLETE reggaeton FLP (drums + melodic MIDI)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
for p in [REF_FLP, CH11_TMPL]:
|
|
||||||
assert os.path.isfile(p), f"MISSING: {p}"
|
|
||||||
|
|
||||||
ref_bytes = open(REF_FLP, "rb").read()
|
|
||||||
num_channels = struct.unpack("<H", ref_bytes[10:12])[0]
|
|
||||||
print(f"Reference FLP: {len(ref_bytes):,} bytes, {num_channels} channels")
|
|
||||||
|
|
||||||
# 1. Load sampler channels (identical to v15)
|
|
||||||
print("\n[1/4] Loading sampler channels...")
|
|
||||||
loader = ChannelSkeletonLoader(REF_FLP, CH11_TMPL, SAMPLES_DIR)
|
|
||||||
sample_map = {
|
|
||||||
"perc1": "perc1.wav", "kick": "kick.wav", "snare": "snare.wav",
|
|
||||||
"rim": "rim.wav", "perc2": "perc2.wav", "hihat": "hihat.wav",
|
|
||||||
"clap": "clap.wav",
|
|
||||||
}
|
|
||||||
channel_bytes = loader.load(sample_map)
|
|
||||||
print(f" Channels: {len(channel_bytes):,} bytes ({num_channels} channels)")
|
|
||||||
|
|
||||||
# 2. Build header + patterns
|
|
||||||
print("\n[2/4] Building header + patterns...")
|
|
||||||
header_bytes = build_header(ref_bytes)
|
|
||||||
pattern_bytes = build_all_patterns()
|
|
||||||
print(f" Header: {len(header_bytes):,} bytes")
|
|
||||||
print(f" Patterns: {len(pattern_bytes):,} bytes ({len(PATTERNS)} patterns)")
|
|
||||||
|
|
||||||
# 3. Build arrangement
|
|
||||||
print("\n[3/4] Building arrangement...")
|
|
||||||
track_data_template = build_track_data_template(ref_bytes)
|
|
||||||
items = [
|
|
||||||
ArrangementItem(
|
|
||||||
pattern_id=it["pattern"], bar=it["bar"],
|
|
||||||
num_bars=it["bars"], track_index=it["track"],
|
|
||||||
)
|
|
||||||
for it in ARRANGEMENT_ITEMS
|
|
||||||
]
|
|
||||||
arrangement_bytes = build_arrangement_section(items, track_data_template, ppq=PPQ)
|
|
||||||
print(f" Arrangement: {len(arrangement_bytes):,} bytes ({len(items)} items)")
|
|
||||||
|
|
||||||
# 4. Assemble — identical to v15 builder
|
|
||||||
print("\n[4/4] Assembling FLP...")
|
|
||||||
body = header_bytes + pattern_bytes + channel_bytes + arrangement_bytes
|
|
||||||
|
|
||||||
flp = (
|
|
||||||
struct.pack("<4sIhHH", b"FLhd", 6, 0, num_channels, PPQ)
|
|
||||||
+ b"FLdt" + struct.pack("<I", len(body))
|
|
||||||
+ body
|
|
||||||
)
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(FLP_OUT), exist_ok=True)
|
|
||||||
with open(FLP_OUT, "wb") as f:
|
|
||||||
f.write(flp)
|
|
||||||
|
|
||||||
duration = (36 * 4 / BPM) * 60
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"Output: {FLP_OUT}")
|
|
||||||
print(f"Size: {len(flp):,} bytes")
|
|
||||||
print(f"Duration: ~{duration:.0f}s (36 bars at {BPM} BPM)")
|
|
||||||
print(f"Channels: {num_channels} (unchanged from reference)")
|
|
||||||
print(f"Patterns: {len(PATTERNS)} (9 drums + 5 melodic)")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
print()
|
|
||||||
print("MELODIC CHANNELS (assign VSTs manually in FL Studio):")
|
|
||||||
print(f" Ch {CH_808}: 808 Bass -> Serum2")
|
|
||||||
print(f" Ch {CH_PIANO}: Piano -> Pigments")
|
|
||||||
print(f" Ch {CH_LEAD}: Lead -> Serum2")
|
|
||||||
print(f" Ch {CH_PAD}: Pad -> Omnisphere")
|
|
||||||
|
|
||||||
return flp
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
build_complete_reggaeton()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""CLI: build a single FLP from a JSON song definition.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/build_from_json.py <song.json> [--out <output.flp>]
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add project root to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
||||||
|
|
||||||
from src.flp_builder.schema import load_song_json
|
|
||||||
from src.flp_builder.builder import FLPBuilder
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Build FLP from JSON song definition"
|
|
||||||
)
|
|
||||||
parser.add_argument("song_json", help="Path to song .json file")
|
|
||||||
parser.add_argument(
|
|
||||||
"--out", help="Output .flp path (default: same name as JSON)"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
json_path = Path(args.song_json)
|
|
||||||
out_path = (
|
|
||||||
Path(args.out) if args.out else json_path.with_suffix(".flp")
|
|
||||||
)
|
|
||||||
|
|
||||||
song = load_song_json(json_path)
|
|
||||||
builder = FLPBuilder()
|
|
||||||
flp = builder.build(song)
|
|
||||||
|
|
||||||
out_path.write_bytes(flp)
|
|
||||||
print(f"Built {out_path} ({len(flp):,} bytes)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
"""
|
|
||||||
Build a PROFESSIONAL reggaeton FLP with REAL SAMPLES from the user's library.
|
|
||||||
|
|
||||||
Key facts:
|
|
||||||
- Only Ch10-19 are sampler channels in the reference FLP (Ch0-9 are VST/plugin)
|
|
||||||
- Each sampler channel loads a real WAV from libreria/reggaeton/
|
|
||||||
- MIDI notes trigger those real samples
|
|
||||||
- 10 channels = kick, snare, hihat, 808, bell, lead, pad, clap, perc, rim
|
|
||||||
|
|
||||||
Sample selection (professional reggaeton):
|
|
||||||
Ch10: kick nes 1 — classic reggaeton kick
|
|
||||||
Ch11: snare nes 1 — clean reggaeton snare
|
|
||||||
Ch12: hi-hat 1 — tight hihat
|
|
||||||
Ch13: Bass Reventado — deep 808 bass (dastin.prod)
|
|
||||||
Ch14: bell 4 — bell tone for chords
|
|
||||||
Ch15: lead 3 — melodic lead
|
|
||||||
Ch16: pad 1 — sustained pad
|
|
||||||
Ch17: clap — reggaeton clap (using snap from perc loop)
|
|
||||||
Ch18: perc 1 — perc one shot
|
|
||||||
Ch19: rim — rim/rimshot
|
|
||||||
|
|
||||||
Output: output/reggaeton_fuego.flp
|
|
||||||
"""
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# ── Paths ──────────────────────────────────────────────────────────────────────
|
|
||||||
BASE = r"C:\Users\Administrator\Documents\fl_control"
|
|
||||||
CH11_TMPL = os.path.join(BASE, "output", "ch11_kick_template.bin")
|
|
||||||
REF_FLP = os.path.join(BASE, r"my space ryt\my space ryt.flp")
|
|
||||||
FLP_OUT = os.path.join(BASE, "output", "reggaeton_fuego.flp")
|
|
||||||
|
|
||||||
# All samples copied here — clean names, no special chars
|
|
||||||
SAMPLES_DIR = os.path.join(BASE, "output", "fuego_samples")
|
|
||||||
|
|
||||||
sys.path.insert(0, BASE)
|
|
||||||
|
|
||||||
from src.flp_builder.events import (
|
|
||||||
EventID,
|
|
||||||
encode_text_event,
|
|
||||||
encode_word_event,
|
|
||||||
encode_data_event,
|
|
||||||
encode_notes_block,
|
|
||||||
)
|
|
||||||
from src.flp_builder.skeleton import ChannelSkeletonLoader
|
|
||||||
from src.flp_builder.arrangement import (
|
|
||||||
ArrangementItem,
|
|
||||||
build_arrangement_section,
|
|
||||||
build_track_data_template,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
||||||
BPM = 95
|
|
||||||
PPQ = 96
|
|
||||||
|
|
||||||
# Channel indices — ALL sampler channels (10-19)
|
|
||||||
CH_KICK = 10
|
|
||||||
CH_SNARE = 11
|
|
||||||
CH_HH = 12
|
|
||||||
CH_808 = 13
|
|
||||||
CH_BELL = 14
|
|
||||||
CH_LEAD = 15
|
|
||||||
CH_PAD = 16
|
|
||||||
CH_CLAP = 17
|
|
||||||
CH_PERC = 18
|
|
||||||
CH_RIM = 19
|
|
||||||
|
|
||||||
|
|
||||||
# Sample assignment: ch_idx → (samples_dir, wav_filename)
|
|
||||||
# All samples in fuego_samples/ with clean names
|
|
||||||
SAMPLE_ASSIGNMENT = {
|
|
||||||
CH_KICK: (SAMPLES_DIR, "kick.wav"),
|
|
||||||
CH_SNARE: (SAMPLES_DIR, "snare.wav"),
|
|
||||||
CH_HH: (SAMPLES_DIR, "hihat.wav"),
|
|
||||||
CH_808: (SAMPLES_DIR, "bass_808.wav"),
|
|
||||||
CH_BELL: (SAMPLES_DIR, "bell.wav"),
|
|
||||||
CH_LEAD: (SAMPLES_DIR, "lead.wav"),
|
|
||||||
CH_PAD: (SAMPLES_DIR, "pad.wav"),
|
|
||||||
CH_CLAP: (SAMPLES_DIR, "clap.wav"),
|
|
||||||
CH_PERC: (SAMPLES_DIR, "perc.wav"),
|
|
||||||
CH_RIM: (SAMPLES_DIR, "rim.wav"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# FUEGO CHORD PROGRESSION: Am → Dm → F → E
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
PROGRESSION = [
|
|
||||||
{"name": "Am", "bass": 33, "chord": [45,48,52,57], "triad": [57,60,64], "root": 69},
|
|
||||||
{"name": "Dm", "bass": 38, "chord": [50,53,57,62], "triad": [62,65,69], "root": 74},
|
|
||||||
{"name": "F", "bass": 41, "chord": [53,57,60,65], "triad": [65,69,72], "root": 77},
|
|
||||||
{"name": "E", "bass": 40, "chord": [52,56,59,64], "triad": [64,68,71], "root": 76},
|
|
||||||
]
|
|
||||||
BEATS_PER_CHORD = 8
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# DRUM GENERATORS — using correct channel indices
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _n(pos, length, ch, vel):
|
|
||||||
return {"pos": pos, "len": length, "key": 60, "vel": max(1, min(127, vel))}
|
|
||||||
|
|
||||||
|
|
||||||
def dembow_kick(bars, vel_mult=1.0):
|
|
||||||
"""REAL dembow: 0.0, 2.0, 3.25"""
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
notes.append(_n(o, 0.25, CH_KICK, int(120 * vel_mult)))
|
|
||||||
notes.append(_n(o + 2.0, 0.25, CH_KICK, int(110 * vel_mult)))
|
|
||||||
notes.append(_n(o + 3.25, 0.15, CH_KICK, int(90 * vel_mult)))
|
|
||||||
return {CH_KICK: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def perreador_kick(bars, vel_mult=1.0):
|
|
||||||
"""Perreador: every beat + offbeat ghosts."""
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
for beat in range(4):
|
|
||||||
notes.append(_n(o + beat, 0.25, CH_KICK, int(115 * vel_mult)))
|
|
||||||
notes.append(_n(o + beat + 0.5, 0.15, CH_KICK, int(80 * vel_mult)))
|
|
||||||
return {CH_KICK: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def sparse_kick(bars, vel_mult=1.0):
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
notes.append(_n(b * 4.0, 0.25, CH_KICK, int(100 * vel_mult)))
|
|
||||||
return {CH_KICK: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def snare_standard(bars, vel_mult=1.0):
|
|
||||||
"""Snare: beats 2, 3-and (positions 1.25, 3.0)."""
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
notes.append(_n(o + 1.25, 0.15, CH_SNARE, int(105 * vel_mult)))
|
|
||||||
notes.append(_n(o + 3.0, 0.15, CH_SNARE, int(100 * vel_mult)))
|
|
||||||
return {CH_SNARE: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def snare_intense(bars, vel_mult=1.0):
|
|
||||||
"""Intense snare with ghost hits."""
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
notes.append(_n(o + 1.25, 0.15, CH_SNARE, int(110 * vel_mult)))
|
|
||||||
notes.append(_n(o + 1.75, 0.10, CH_SNARE, int(70 * vel_mult)))
|
|
||||||
notes.append(_n(o + 3.0, 0.15, CH_SNARE, int(105 * vel_mult)))
|
|
||||||
notes.append(_n(o + 3.5, 0.10, CH_SNARE, int(65 * vel_mult)))
|
|
||||||
return {CH_SNARE: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def hihat_offbeat(bars, vel_mult=1.0):
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
for i in range(4):
|
|
||||||
notes.append(_n(o + i + 0.5, 0.1, CH_HH, int(55 * vel_mult)))
|
|
||||||
return {CH_HH: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def hihat_8th(bars, vel_mult=1.0):
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
for i in range(8):
|
|
||||||
v = 70 if i % 2 == 0 else 50
|
|
||||||
notes.append(_n(o + i * 0.5, 0.1, CH_HH, int(v * vel_mult)))
|
|
||||||
return {CH_HH: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def hihat_16th(bars, vel_mult=1.0):
|
|
||||||
"""Full 16ths with accents and open hats."""
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
for i in range(16):
|
|
||||||
p = i * 0.25
|
|
||||||
if p % 1.0 == 0.0:
|
|
||||||
v, l = 90, 0.1
|
|
||||||
elif p % 0.5 == 0.0:
|
|
||||||
v, l = 65, 0.1
|
|
||||||
else:
|
|
||||||
v, l = 40, 0.08
|
|
||||||
if i in [5, 10]:
|
|
||||||
l = 0.2; v = int(v * 1.2)
|
|
||||||
notes.append(_n(o + p, l, CH_HH, int(v * vel_mult)))
|
|
||||||
return {CH_HH: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def clap_standard(bars, vel_mult=1.0):
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
notes.append(_n(o + 1.0, 0.15, CH_CLAP, int(120 * vel_mult)))
|
|
||||||
notes.append(_n(o + 3.0, 0.15, CH_CLAP, int(115 * vel_mult)))
|
|
||||||
return {CH_CLAP: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def clap_soft(bars, vel_mult=1.0):
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
notes.append(_n(o + 1.0, 0.15, CH_CLAP, int(80 * vel_mult)))
|
|
||||||
notes.append(_n(o + 3.0, 0.15, CH_CLAP, int(75 * vel_mult)))
|
|
||||||
return {CH_CLAP: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def perc_offbeat(bars, vel_mult=1.0):
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
notes.append(_n(o + 0.75, 0.1, CH_PERC, int(85 * vel_mult)))
|
|
||||||
notes.append(_n(o + 2.75, 0.1, CH_PERC, int(80 * vel_mult)))
|
|
||||||
return {CH_PERC: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def rim_build(bars, vel_mult=1.0):
|
|
||||||
"""Rim roll building intensity."""
|
|
||||||
PATTERNS = [[0,2,8,14], [0,2,4,8,10,14], [0,2,4,6,8,10,12,14], list(range(16))]
|
|
||||||
VELS = [50, 65, 80, 100]
|
|
||||||
notes = []
|
|
||||||
for b in range(bars):
|
|
||||||
o = b * 4.0
|
|
||||||
v = int(VELS[b % 4] * vel_mult)
|
|
||||||
for idx in PATTERNS[b % 4]:
|
|
||||||
notes.append(_n(o + idx * 0.25, 0.1, CH_RIM, v))
|
|
||||||
return {CH_RIM: notes}
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# MELODIC GENERATORS
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _mn(pos, length, key, vel):
|
|
||||||
"""Melodic note — pitch matters."""
|
|
||||||
return {"pos": pos, "len": length, "key": key, "vel": max(1, min(127, vel))}
|
|
||||||
|
|
||||||
|
|
||||||
def bass_808_full(bars, vel_mult=1.0):
|
|
||||||
"""808 bass with chord-root movement + fifth variation."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
r = ch["bass"]
|
|
||||||
f = r + 7
|
|
||||||
v = vel_mult
|
|
||||||
notes.append(_mn(base + 0.0, 2.5, r, int(110*v)))
|
|
||||||
notes.append(_mn(base + 2.5, 0.5, f, int(80*v)))
|
|
||||||
notes.append(_mn(base + 3.0, 2.0, r, int(105*v)))
|
|
||||||
notes.append(_mn(base + 5.0, 1.0, r, int(90*v)))
|
|
||||||
notes.append(_mn(base + 6.0, 0.5, f, int(75*v)))
|
|
||||||
notes.append(_mn(base + 6.5, 1.5, r, int(100*v)))
|
|
||||||
return {CH_808: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def bass_808_sparse(bars, vel_mult=1.0):
|
|
||||||
"""Sparse 808 for intro — just root, long sustain."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
notes.append(_mn(ci * BEATS_PER_CHORD, 7.5, ch["bass"], int(60 * vel_mult)))
|
|
||||||
return {CH_808: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def bell_chords(bars, vel_mult=1.0):
|
|
||||||
"""Bell playing offbeat chord stabs — 4-note voicings."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
stabs = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for sp in stabs:
|
|
||||||
v = int((85 + (hash((ci, sp)) % 10)) * vel_mult)
|
|
||||||
for pitch in ch["triad"]:
|
|
||||||
notes.append(_mn(base + sp, 0.12, pitch, v))
|
|
||||||
return {CH_BELL: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def bell_sparse(bars, vel_mult=1.0):
|
|
||||||
"""Sparse bell for intro — 4-note voicings, beats 2.5 and 6.5."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for sp in [2.5, 6.5]:
|
|
||||||
v = int(60 * vel_mult)
|
|
||||||
for pitch in ch["chord"]:
|
|
||||||
notes.append(_mn(base + sp, 0.15, pitch, v))
|
|
||||||
return {CH_BELL: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def lead_hook(bars, vel_mult=1.0):
|
|
||||||
"""Lead melody — arch contour, chord tones on strong beats."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
lr = ch["root"]
|
|
||||||
c = ch["triad"]
|
|
||||||
v = vel_mult
|
|
||||||
notes.append(_mn(base + 0.0, 1.0, c[0], int(95*v)))
|
|
||||||
notes.append(_mn(base + 1.0, 0.5, c[1], int(85*v)))
|
|
||||||
notes.append(_mn(base + 1.5, 0.5, c[2], int(100*v)))
|
|
||||||
notes.append(_mn(base + 2.0, 1.5, lr, int(105*v)))
|
|
||||||
notes.append(_mn(base + 3.5, 0.5, c[2], int(90*v)))
|
|
||||||
notes.append(_mn(base + 4.0, 0.5, c[1], int(80*v)))
|
|
||||||
notes.append(_mn(base + 4.5, 1.5, c[0], int(95*v)))
|
|
||||||
notes.append(_mn(base + 6.0, 0.5, lr-2, int(75*v)))
|
|
||||||
notes.append(_mn(base + 6.5, 1.5, c[0], int(90*v)))
|
|
||||||
return {CH_LEAD: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def pad_sustained(bars, vel_mult=1.0):
|
|
||||||
"""Sustained pad — 4-note voicings."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for pitch in ch["chord"]:
|
|
||||||
notes.append(_mn(base, 7.5, pitch, int(60 * vel_mult)))
|
|
||||||
return {CH_PAD: notes}
|
|
||||||
|
|
||||||
|
|
||||||
def pad_swell(bars, vel_mult=1.0):
|
|
||||||
"""Pad swell for pre-chorus — crescendo within chord."""
|
|
||||||
notes = []
|
|
||||||
total = bars * 4
|
|
||||||
chords = total // BEATS_PER_CHORD
|
|
||||||
for ci in range(chords):
|
|
||||||
ch = PROGRESSION[ci % 4]
|
|
||||||
base = ci * BEATS_PER_CHORD
|
|
||||||
for pitch in ch["chord"]:
|
|
||||||
notes.append(_mn(base, 4.0, pitch, int(45 * vel_mult)))
|
|
||||||
notes.append(_mn(base + 4, 3.5, pitch, int(70 * vel_mult)))
|
|
||||||
return {CH_PAD: notes}
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PATTERN DEFINITIONS — 20 patterns
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
# All generators return {ch_idx: [notes]}
|
|
||||||
ALL_GENERATORS = {
|
|
||||||
"dembow_kick": dembow_kick,
|
|
||||||
"perreador_kick": perreador_kick,
|
|
||||||
"sparse_kick": sparse_kick,
|
|
||||||
"snare_std": snare_standard,
|
|
||||||
"snare_intense": snare_intense,
|
|
||||||
"hh_offbeat": hihat_offbeat,
|
|
||||||
"hh_8th": hihat_8th,
|
|
||||||
"hh_16th": hihat_16th,
|
|
||||||
"clap_std": clap_standard,
|
|
||||||
"clap_soft": clap_soft,
|
|
||||||
"perc_offbeat": perc_offbeat,
|
|
||||||
"rim_build": rim_build,
|
|
||||||
"bass_full": bass_808_full,
|
|
||||||
"bass_sparse": bass_808_sparse,
|
|
||||||
"bell_chords": bell_chords,
|
|
||||||
"bell_sparse": bell_sparse,
|
|
||||||
"lead_hook": lead_hook,
|
|
||||||
"pad_sustained": pad_sustained,
|
|
||||||
"pad_swell": pad_swell,
|
|
||||||
}
|
|
||||||
|
|
||||||
PATTERNS = [
|
|
||||||
{"id": 1, "name": "Kick Dembow", "gen": "dembow_kick", "bars": 8},
|
|
||||||
{"id": 2, "name": "Kick Perreador", "gen": "perreador_kick","bars": 8},
|
|
||||||
{"id": 3, "name": "Kick Sparse", "gen": "sparse_kick", "bars": 8},
|
|
||||||
{"id": 4, "name": "Snare Standard", "gen": "snare_std", "bars": 8},
|
|
||||||
{"id": 5, "name": "Snare Intense", "gen": "snare_intense", "bars": 8},
|
|
||||||
{"id": 6, "name": "HH Offbeat", "gen": "hh_offbeat", "bars": 8},
|
|
||||||
{"id": 7, "name": "HH 8th", "gen": "hh_8th", "bars": 8},
|
|
||||||
{"id": 8, "name": "HH 16th Full", "gen": "hh_16th", "bars": 8},
|
|
||||||
{"id": 9, "name": "Clap Standard", "gen": "clap_std", "bars": 8},
|
|
||||||
{"id": 10, "name": "Perc Offbeat", "gen": "perc_offbeat", "bars": 8},
|
|
||||||
{"id": 11, "name": "Rim Build", "gen": "rim_build", "bars": 4},
|
|
||||||
{"id": 12, "name": "808 Bass Full", "gen": "bass_full", "bars": 8},
|
|
||||||
{"id": 13, "name": "808 Bass Sparse", "gen": "bass_sparse", "bars": 8},
|
|
||||||
{"id": 14, "name": "Bell Chords", "gen": "bell_chords", "bars": 8},
|
|
||||||
{"id": 15, "name": "Bell Sparse", "gen": "bell_sparse", "bars": 8},
|
|
||||||
{"id": 16, "name": "Lead Hook", "gen": "lead_hook", "bars": 8},
|
|
||||||
{"id": 17, "name": "Pad Sustained", "gen": "pad_sustained", "bars": 8},
|
|
||||||
{"id": 18, "name": "Pad Swell", "gen": "pad_swell", "bars": 8},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# ARRANGEMENT — 48 bars, 7 sections
|
|
||||||
# 10 tracks (one per sampler channel Ch10-19)
|
|
||||||
# Track index in arrangement: 0=kick, 1=snare, 2=hh, 3=808, 4=bell,
|
|
||||||
# 5=lead, 6=pad, 7=clap, 8=perc, 9=rim
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
ARRANGEMENT_ITEMS = [
|
|
||||||
# INTRO (0-4): ghostly, sparse
|
|
||||||
{"pattern": 3, "bar": 0, "bars": 4, "track": 0}, # sparse kick
|
|
||||||
{"pattern": 6, "bar": 0, "bars": 4, "track": 2}, # offbeat HH
|
|
||||||
{"pattern": 13, "bar": 0, "bars": 4, "track": 3}, # sparse 808
|
|
||||||
{"pattern": 15, "bar": 0, "bars": 4, "track": 4}, # sparse bell
|
|
||||||
{"pattern": 17, "bar": 0, "bars": 4, "track": 6}, # pad sustained
|
|
||||||
|
|
||||||
# VERSE 1 (4-12): warming up
|
|
||||||
{"pattern": 1, "bar": 4, "bars": 8, "track": 0}, # dembow kick
|
|
||||||
{"pattern": 4, "bar": 4, "bars": 8, "track": 1}, # snare std
|
|
||||||
{"pattern": 7, "bar": 4, "bars": 8, "track": 2}, # HH 8th
|
|
||||||
{"pattern": 12, "bar": 4, "bars": 8, "track": 3}, # 808 full
|
|
||||||
{"pattern": 15, "bar": 4, "bars": 8, "track": 4}, # sparse bell
|
|
||||||
{"pattern": 17, "bar": 4, "bars": 8, "track": 6}, # pad
|
|
||||||
|
|
||||||
# PRE-CHORUS (12-16): building tension
|
|
||||||
{"pattern": 1, "bar": 12, "bars": 4, "track": 0}, # dembow kick
|
|
||||||
{"pattern": 5, "bar": 12, "bars": 4, "track": 1}, # snare intense
|
|
||||||
{"pattern": 11, "bar": 12, "bars": 4, "track": 9}, # rim build
|
|
||||||
{"pattern": 7, "bar": 12, "bars": 4, "track": 2}, # HH 8th
|
|
||||||
{"pattern": 12, "bar": 12, "bars": 4, "track": 3}, # 808 full
|
|
||||||
{"pattern": 14, "bar": 12, "bars": 4, "track": 4}, # bell chords
|
|
||||||
{"pattern": 18, "bar": 12, "bars": 4, "track": 6}, # pad swell
|
|
||||||
|
|
||||||
# CHORUS (16-24): FULL ENERGY
|
|
||||||
{"pattern": 2, "bar": 16, "bars": 8, "track": 0}, # perreador kick!
|
|
||||||
{"pattern": 5, "bar": 16, "bars": 8, "track": 1}, # snare intense
|
|
||||||
{"pattern": 8, "bar": 16, "bars": 8, "track": 2}, # HH 16th
|
|
||||||
{"pattern": 9, "bar": 16, "bars": 8, "track": 7}, # clap
|
|
||||||
{"pattern": 10, "bar": 16, "bars": 8, "track": 8}, # perc offbeat
|
|
||||||
{"pattern": 12, "bar": 16, "bars": 8, "track": 3}, # 808 full
|
|
||||||
{"pattern": 14, "bar": 16, "bars": 8, "track": 4}, # bell chords
|
|
||||||
{"pattern": 16, "bar": 16, "bars": 8, "track": 5}, # lead hook
|
|
||||||
{"pattern": 17, "bar": 16, "bars": 8, "track": 6}, # pad
|
|
||||||
|
|
||||||
# VERSE 2 (24-32): energy maintained, no lead
|
|
||||||
{"pattern": 1, "bar": 24, "bars": 8, "track": 0}, # dembow kick
|
|
||||||
{"pattern": 4, "bar": 24, "bars": 8, "track": 1}, # snare std
|
|
||||||
{"pattern": 7, "bar": 24, "bars": 8, "track": 2}, # HH 8th
|
|
||||||
{"pattern": 9, "bar": 24, "bars": 8, "track": 7}, # clap
|
|
||||||
{"pattern": 12, "bar": 24, "bars": 8, "track": 3}, # 808 full
|
|
||||||
{"pattern": 14, "bar": 24, "bars": 8, "track": 4}, # bell chords
|
|
||||||
{"pattern": 17, "bar": 24, "bars": 8, "track": 6}, # pad
|
|
||||||
|
|
||||||
# BREAKDOWN (32-36): stripped
|
|
||||||
{"pattern": 3, "bar": 32, "bars": 4, "track": 0}, # sparse kick
|
|
||||||
{"pattern": 6, "bar": 32, "bars": 4, "track": 2}, # offbeat HH
|
|
||||||
{"pattern": 13, "bar": 32, "bars": 4, "track": 3}, # sparse 808
|
|
||||||
{"pattern": 15, "bar": 32, "bars": 4, "track": 4}, # sparse bell
|
|
||||||
{"pattern": 17, "bar": 32, "bars": 4, "track": 6}, # pad
|
|
||||||
|
|
||||||
# OUTRO (36-48): fading
|
|
||||||
{"pattern": 1, "bar": 36, "bars": 12, "track": 0}, # dembow kick
|
|
||||||
{"pattern": 4, "bar": 36, "bars": 12, "track": 1}, # snare std
|
|
||||||
{"pattern": 7, "bar": 36, "bars": 12, "track": 2}, # HH 8th
|
|
||||||
{"pattern": 17, "bar": 36, "bars": 12, "track": 6}, # pad
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# HEADER BUILDER
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _read_ev(data, pos):
|
|
||||||
s = pos
|
|
||||||
ib = data[pos]; pos += 1
|
|
||||||
if ib < 64: return pos + 1, s, ib, data[s + 1], "byte"
|
|
||||||
elif ib < 128: return pos + 2, s, ib, struct.unpack("<H", data[pos:pos + 2])[0], "word"
|
|
||||||
elif ib < 192: return pos + 4, s, ib, struct.unpack("<I", data[pos:pos + 4])[0], "dword"
|
|
||||||
else:
|
|
||||||
sz = 0; sh = 0
|
|
||||||
while True:
|
|
||||||
b = data[pos]; pos += 1
|
|
||||||
sz |= (b & 0x7F) << sh; sh += 7
|
|
||||||
if not (b & 0x80): break
|
|
||||||
return pos + sz, s, ib, data[pos:pos + sz], "data"
|
|
||||||
|
|
||||||
|
|
||||||
def build_header(ref_bytes):
|
|
||||||
pos = 22
|
|
||||||
first_pat = None
|
|
||||||
while pos < len(ref_bytes):
|
|
||||||
np, st, ib, val, vt = _read_ev(ref_bytes, pos)
|
|
||||||
if ib == EventID.PatNew:
|
|
||||||
first_pat = st
|
|
||||||
break
|
|
||||||
pos = np
|
|
||||||
if first_pat is None:
|
|
||||||
raise ValueError("No PatNew found")
|
|
||||||
header = bytearray(ref_bytes[22:first_pat])
|
|
||||||
p = 0
|
|
||||||
while p < len(header):
|
|
||||||
np, _, ib, val, vt = _read_ev(bytes(header), p)
|
|
||||||
if ib == EventID.Tempo:
|
|
||||||
struct.pack_into("<I", header, p + 1, BPM * 1000)
|
|
||||||
break
|
|
||||||
p = np
|
|
||||||
return bytes(header)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PATTERN BUILDER
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _conv(notes):
|
|
||||||
return [{"position": n["pos"], "length": n["len"], "key": n["key"], "velocity": n["vel"]} for n in notes]
|
|
||||||
|
|
||||||
|
|
||||||
def build_all_patterns():
|
|
||||||
buf = bytearray()
|
|
||||||
for pat_def in PATTERNS:
|
|
||||||
buf += encode_word_event(EventID.PatNew, pat_def["id"] - 1)
|
|
||||||
buf += encode_text_event(EventID.PatName, pat_def["name"])
|
|
||||||
notes_by_ch = ALL_GENERATORS[pat_def["gen"]](pat_def["bars"])
|
|
||||||
for ch_idx, raw_notes in notes_by_ch.items():
|
|
||||||
if not raw_notes:
|
|
||||||
continue
|
|
||||||
buf += encode_data_event(EventID.PatNotes, encode_notes_block(ch_idx, _conv(raw_notes), PPQ))
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# MAIN BUILD
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def build_fuego():
|
|
||||||
print("=" * 60)
|
|
||||||
print("FUEGO reggaeton — REAL SAMPLES from library")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"Progression: Am -> Dm -> F -> E")
|
|
||||||
print(f"Samples from: libreria/reggaeton/")
|
|
||||||
print(f"Channels: Ch10-19 (all sampler)")
|
|
||||||
print(f"Arrangement: 48 bars, 7 sections")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
assert os.path.isfile(REF_FLP), f"MISSING: {REF_FLP}"
|
|
||||||
ref_bytes = open(REF_FLP, "rb").read()
|
|
||||||
num_channels = struct.unpack("<H", ref_bytes[10:12])[0]
|
|
||||||
print(f"\nReference: {len(ref_bytes):,} bytes, {num_channels} channels")
|
|
||||||
|
|
||||||
# 1. Load channels — ALL 10 samplers get real samples from fuego_samples/
|
|
||||||
print("\n[1/4] Loading channels with real samples...")
|
|
||||||
loader = ChannelSkeletonLoader(REF_FLP, CH11_TMPL, SAMPLES_DIR)
|
|
||||||
|
|
||||||
# All channels use samples from fuego_samples/
|
|
||||||
channel_bytes = loader.load(melodic_map=SAMPLE_ASSIGNMENT)
|
|
||||||
print(f" Channels: {len(channel_bytes):,} bytes")
|
|
||||||
for ch_idx in sorted(SAMPLE_ASSIGNMENT.keys()):
|
|
||||||
_, w = SAMPLE_ASSIGNMENT[ch_idx]
|
|
||||||
print(f" Ch{ch_idx}: {w}")
|
|
||||||
|
|
||||||
# 2. Build header + patterns
|
|
||||||
print("\n[2/4] Building header + patterns...")
|
|
||||||
header_bytes = build_header(ref_bytes)
|
|
||||||
pattern_bytes = build_all_patterns()
|
|
||||||
print(f" Header: {len(header_bytes):,} bytes")
|
|
||||||
print(f" Patterns: {len(pattern_bytes):,} bytes ({len(PATTERNS)} patterns)")
|
|
||||||
|
|
||||||
# 3. Build arrangement
|
|
||||||
print("\n[3/4] Building arrangement...")
|
|
||||||
track_data_template = build_track_data_template(ref_bytes)
|
|
||||||
items = [
|
|
||||||
ArrangementItem(
|
|
||||||
pattern_id=it["pattern"], bar=it["bar"],
|
|
||||||
num_bars=it["bars"], track_index=it["track"],
|
|
||||||
)
|
|
||||||
for it in ARRANGEMENT_ITEMS
|
|
||||||
]
|
|
||||||
arrangement_bytes = build_arrangement_section(items, track_data_template, ppq=PPQ)
|
|
||||||
print(f" Arrangement: {len(arrangement_bytes):,} bytes ({len(items)} items)")
|
|
||||||
|
|
||||||
# 4. Assemble
|
|
||||||
print("\n[4/4] Assembling FLP...")
|
|
||||||
body = header_bytes + pattern_bytes + channel_bytes + arrangement_bytes
|
|
||||||
flp = (
|
|
||||||
struct.pack("<4sIhHH", b"FLhd", 6, 0, num_channels, PPQ)
|
|
||||||
+ b"FLdt" + struct.pack("<I", len(body))
|
|
||||||
+ body
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(FLP_OUT, "wb") as f:
|
|
||||||
f.write(flp)
|
|
||||||
|
|
||||||
duration = (48 * 4 / BPM) * 60
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f" Output: {FLP_OUT}")
|
|
||||||
print(f" Size: {len(flp):,} bytes")
|
|
||||||
print(f" Duration: ~{duration:.0f}s (48 bars @ {BPM} BPM)")
|
|
||||||
print(f" Channels: {num_channels} (Ch0-9 plugin, Ch10-19 sampler)")
|
|
||||||
print(f" Patterns: {len(PATTERNS)}")
|
|
||||||
print(f" Sections: INTRO -> VERSE1 -> PRE-CHORUS -> CHORUS -> VERSE2 -> BREAKDOWN -> OUTRO")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
return flp
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
build_fuego()
|
|
||||||
@@ -1,70 +1,204 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Compose and build in one step from genre knowledge base."""
|
"""Compose a REAPER .rpp project from the sample library.
|
||||||
import sys
|
|
||||||
import os
|
Single entrypoint: loads sample index, builds a SongDefinition from the selector/composer,
|
||||||
import json
|
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 argparse
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
# Ensure project root on path
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
from src.composer import compose_from_genre
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||||
from scripts.build import build_project
|
from src.composer.rhythm import get_notes
|
||||||
from src.flp_builder.writer import FLPWriter
|
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
||||||
|
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
||||||
KNOWLEDGE_DIR = Path(__file__).parent.parent / "knowledge" / "genres"
|
from src.selector import SampleSelector
|
||||||
OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
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")
|
# Track builders
|
||||||
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")
|
def build_drum_track(
|
||||||
parser.add_argument("--bars", type=int, default=None, help="Override bar count")
|
role: str,
|
||||||
parser.add_argument("--output", "-o", default=None, help="Output .flp path")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
genre_file = KNOWLEDGE_DIR / f"{args.genre}.json"
|
# Validate BPM before any writes
|
||||||
if not genre_file.exists():
|
if args.bpm <= 0:
|
||||||
print(json.dumps({"error": f"Genre not found: {genre_file}", "available": [p.stem for p in KNOWLEDGE_DIR.glob("*.json")]}))
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
overrides = {}
|
selector = SampleSelector(str(index_path))
|
||||||
if args.key:
|
|
||||||
overrides["keys"] = [args.key]
|
|
||||||
if args.bpm:
|
|
||||||
overrides["bpm"] = {"default": args.bpm}
|
|
||||||
if args.bars:
|
|
||||||
overrides["structure"] = {"sections": [{"bars": args.bars}]}
|
|
||||||
|
|
||||||
composition = compose_from_genre(str(genre_file), overrides if overrides else None)
|
# Determine bar count from genre
|
||||||
project = build_project(composition)
|
genre_bar_map = {
|
||||||
|
"reggaeton": 64,
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
"trap": 32,
|
||||||
output_path = args.output or str(
|
"house": 64,
|
||||||
OUTPUT_DIR / f"{args.genre}_{composition['meta']['key']}_{composition['meta']['bpm']}bpm.flp"
|
"drill": 32,
|
||||||
)
|
|
||||||
|
|
||||||
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", [])),
|
|
||||||
}
|
}
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""compose_full_track.py — Genera un FLP reggaeton completo de 2:30."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# ── resolve project root ──────────────────────────────────────────────────────
|
|
||||||
PROJECT = Path(__file__).resolve().parents[1]
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, str(PROJECT))
|
|
||||||
|
|
||||||
from src.selector import SampleSelector
|
|
||||||
from src.composer.melodic import bass_tresillo, lead_hook, pad_sustain
|
|
||||||
from src.flp_builder.schema import (
|
|
||||||
SongDefinition, SongMeta, PatternDef, ArrangementTrack,
|
|
||||||
ArrangementItemDef, MelodicTrack, MelodicNote
|
|
||||||
)
|
|
||||||
from src.flp_builder.builder import FLPBuilder
|
|
||||||
|
|
||||||
# ── timing ────────────────────────────────────────────────────────────────────
|
|
||||||
BPM = 95
|
|
||||||
KEY = "Am"
|
|
||||||
BARS_TOTAL = 60
|
|
||||||
BAR_DURATION_S = 4 / BPM * 60 # 2.526s/bar
|
|
||||||
SONG_DURATION_S = BARS_TOTAL * BAR_DURATION_S
|
|
||||||
SONG_DURATION_M = int(SONG_DURATION_S // 60), int(SONG_DURATION_S % 60)
|
|
||||||
|
|
||||||
print(f"Target: {BARS_TOTAL} bars @ {BPM} BPM = {SONG_DURATION_M[0]}:{SONG_DURATION_M[1]:02d}")
|
|
||||||
|
|
||||||
# ── sample selector ────────────────────────────────────────────────────────────
|
|
||||||
sel = SampleSelector()
|
|
||||||
|
|
||||||
def pick(role, **kwargs):
|
|
||||||
"""Select best sample, log warning if missing."""
|
|
||||||
m = sel.select_one(role=role, bpm=BPM, **kwargs)
|
|
||||||
if m:
|
|
||||||
return m.get("new_name") or m.get("original_name"), m.get("original_path") or ""
|
|
||||||
print(f" [WARN] No sample for role='{role}' {kwargs}")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# Drum channels (10-16)
|
|
||||||
ch10_perc, path10 = pick("perc")
|
|
||||||
ch11_kick, path11 = pick("kick")
|
|
||||||
ch12_snare, path12 = pick("snare")
|
|
||||||
ch13_rim, path13 = pick("perc") # fallback to perc
|
|
||||||
ch14_perc2, path14 = pick("perc", character="aggressive")
|
|
||||||
ch15_hihat, path15 = pick("hihat")
|
|
||||||
ch16_clap, path16 = pick("snare", character="aggressive")
|
|
||||||
|
|
||||||
print("Drum samples:")
|
|
||||||
for ch, name in [(10, ch10_perc),(11, ch11_kick),(12, ch12_snare),
|
|
||||||
(13, ch13_rim),(14, ch14_perc2),(15, ch15_hihat),(16, ch16_clap)]:
|
|
||||||
print(f" ch{ch}: {name}")
|
|
||||||
|
|
||||||
# Melodic samples
|
|
||||||
ch17_bass_path = sel.select_one(role="bass", key=KEY, bpm=BPM, character="deep")["original_path"]
|
|
||||||
ch18_lead_path = sel.select_one(role="lead", key=KEY, bpm=BPM, character="bright")["original_path"]
|
|
||||||
ch19_pad_path = sel.select_one(role="pad", key=KEY, bpm=BPM, character="warm")["original_path"]
|
|
||||||
ch20_pluck_path = sel.select_one(role="pluck", key=KEY, bpm=BPM, character="warm")["original_path"]
|
|
||||||
|
|
||||||
print(f"\nMelodic samples:")
|
|
||||||
for ch, path in [(17, ch17_bass_path),(18, ch18_lead_path),(19, ch19_pad_path),(20, ch20_pluck_path)]:
|
|
||||||
print(f" ch{ch}: {Path(path).name}")
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
def section_notes(generator_fn, key, bars, start_bar, **kwargs):
|
|
||||||
"""Generate notes from a melodic generator, offset to start_bar."""
|
|
||||||
raw = generator_fn(key, bars=bars, **kwargs)
|
|
||||||
return [
|
|
||||||
MelodicNote(
|
|
||||||
pos=n["pos"] + start_bar * 4.0,
|
|
||||||
len=n["len"],
|
|
||||||
key=n["key"],
|
|
||||||
vel=n["vel"]
|
|
||||||
)
|
|
||||||
for n in raw
|
|
||||||
]
|
|
||||||
|
|
||||||
def place_items(pattern_id, start_bar, total_bars, track_idx, pat_bars=4):
|
|
||||||
"""Generate ArrangementItemDefs to fill total_bars with pat_bars chunks."""
|
|
||||||
items = []
|
|
||||||
for b in range(0, total_bars, pat_bars):
|
|
||||||
items.append(ArrangementItemDef(
|
|
||||||
pattern=pattern_id,
|
|
||||||
bar=start_bar + b,
|
|
||||||
bars=pat_bars,
|
|
||||||
track=track_idx,
|
|
||||||
))
|
|
||||||
return items
|
|
||||||
|
|
||||||
# ── melodic notes (only in sections where they play) ─────────────────────────
|
|
||||||
print("\nGenerating melodic notes...")
|
|
||||||
|
|
||||||
# Bass: verse1, chorus1, verse2, chorus2 (not intro/outro)
|
|
||||||
bass_notes = (
|
|
||||||
section_notes(bass_tresillo, KEY, 12, 8, octave=3) +
|
|
||||||
section_notes(bass_tresillo, KEY, 12, 20, octave=3) +
|
|
||||||
section_notes(bass_tresillo, KEY, 12, 32, octave=3) +
|
|
||||||
section_notes(bass_tresillo, KEY, 12, 44, octave=3)
|
|
||||||
)
|
|
||||||
print(f" Bass: {len(bass_notes)} notes")
|
|
||||||
|
|
||||||
# Lead: chorus1 + chorus2 only
|
|
||||||
lead_notes = (
|
|
||||||
section_notes(lead_hook, KEY, 12, 20, octave=5) +
|
|
||||||
section_notes(lead_hook, KEY, 12, 44, octave=5)
|
|
||||||
)
|
|
||||||
print(f" Lead: {len(lead_notes)} notes")
|
|
||||||
|
|
||||||
# Pad: verse1, chorus1, verse2, chorus2
|
|
||||||
pad_notes = (
|
|
||||||
section_notes(pad_sustain, KEY, 12, 8, octave=4) +
|
|
||||||
section_notes(pad_sustain, KEY, 12, 20, octave=4) +
|
|
||||||
section_notes(pad_sustain, KEY, 12, 32, octave=4) +
|
|
||||||
section_notes(pad_sustain, KEY, 12, 44, octave=4)
|
|
||||||
)
|
|
||||||
print(f" Pad: {len(pad_notes)} notes")
|
|
||||||
|
|
||||||
# Pluck: verse1 + verse2 (harmonic fill)
|
|
||||||
pluck_notes = (
|
|
||||||
section_notes(lead_hook, KEY, 12, 8, octave=5, density=0.4) +
|
|
||||||
section_notes(lead_hook, KEY, 12, 32, octave=5, density=0.4)
|
|
||||||
)
|
|
||||||
print(f" Pluck: {len(pluck_notes)} notes")
|
|
||||||
|
|
||||||
# ── SongDefinition ─────────────────────────────────────────────────────────────
|
|
||||||
meta = SongMeta(bpm=BPM, key=KEY, title=f"Reggaeton Full {SONG_DURATION_M[0]}:{SONG_DURATION_M[1]:02d}")
|
|
||||||
|
|
||||||
patterns = [
|
|
||||||
PatternDef(id=1, name="kick_sparse", instrument="kick", channel=11, bars=4, generator="kick_sparse_notes", velocity_mult=0.7),
|
|
||||||
PatternDef(id=2, name="kick_main", instrument="kick", channel=11, bars=4, generator="kick_main_notes"),
|
|
||||||
PatternDef(id=3, name="snare_main", instrument="snare", channel=12, bars=4, generator="snare_verse_notes"),
|
|
||||||
PatternDef(id=4, name="hihat_main", instrument="hihat", channel=15, bars=4, generator="hihat_16th_notes"),
|
|
||||||
PatternDef(id=5, name="clap_main", instrument="clap", channel=16, bars=4, generator="clap_24_notes"),
|
|
||||||
PatternDef(id=6, name="perc_main", instrument="perc", channel=10, bars=4, generator="perc_combo_notes"),
|
|
||||||
PatternDef(id=7, name="perc2_main", instrument="perc", channel=14, bars=4, generator="perc_combo_notes"),
|
|
||||||
PatternDef(id=8, name="hihat_intro", instrument="hihat", channel=15, bars=4, generator="hihat_8th_notes", velocity_mult=0.8),
|
|
||||||
]
|
|
||||||
|
|
||||||
tracks = [
|
|
||||||
ArrangementTrack(index=1, name="Kick"),
|
|
||||||
ArrangementTrack(index=2, name="Snare"),
|
|
||||||
ArrangementTrack(index=3, name="HiHat"),
|
|
||||||
ArrangementTrack(index=4, name="Clap"),
|
|
||||||
ArrangementTrack(index=5, name="Perc"),
|
|
||||||
ArrangementTrack(index=6, name="Perc2"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── arrangement items ──────────────────────────────────────────────────────────
|
|
||||||
items: list[ArrangementItemDef] = []
|
|
||||||
|
|
||||||
# INTRO (0-7): kick sparse + hihat 8th
|
|
||||||
items += place_items(1, 0, 8, 1) # kick sparse
|
|
||||||
items += place_items(8, 0, 8, 3) # hihat intro (8th notes)
|
|
||||||
|
|
||||||
# VERSE 1 (8-19): kick, snare, hihat, perc (no clap)
|
|
||||||
items += place_items(2, 8, 12, 1) # kick main
|
|
||||||
items += place_items(3, 8, 12, 2) # snare
|
|
||||||
items += place_items(4, 8, 12, 3) # hihat
|
|
||||||
items += place_items(6, 8, 12, 5) # perc1
|
|
||||||
items += place_items(7, 8, 12, 6) # perc2
|
|
||||||
|
|
||||||
# CHORUS 1 (20-31): all drums
|
|
||||||
items += place_items(2, 20, 12, 1) # kick
|
|
||||||
items += place_items(3, 20, 12, 2) # snare
|
|
||||||
items += place_items(4, 20, 12, 3) # hihat
|
|
||||||
items += place_items(5, 20, 12, 4) # clap
|
|
||||||
items += place_items(6, 20, 12, 5) # perc1
|
|
||||||
items += place_items(7, 20, 12, 6) # perc2
|
|
||||||
|
|
||||||
# VERSE 2 (32-43)
|
|
||||||
items += place_items(2, 32, 12, 1) # kick
|
|
||||||
items += place_items(3, 32, 12, 2) # snare
|
|
||||||
items += place_items(4, 32, 12, 3) # hihat
|
|
||||||
items += place_items(6, 32, 12, 5) # perc1
|
|
||||||
items += place_items(7, 32, 12, 6) # perc2
|
|
||||||
|
|
||||||
# CHORUS 2 (44-55)
|
|
||||||
items += place_items(2, 44, 12, 1) # kick
|
|
||||||
items += place_items(3, 44, 12, 2) # snare
|
|
||||||
items += place_items(4, 44, 12, 3) # hihat
|
|
||||||
items += place_items(5, 44, 12, 4) # clap
|
|
||||||
items += place_items(6, 44, 12, 5) # perc1
|
|
||||||
items += place_items(7, 44, 12, 6) # perc2
|
|
||||||
|
|
||||||
# OUTRO (56-59): kick sparse + hihat
|
|
||||||
items += place_items(1, 56, 4, 1) # kick sparse
|
|
||||||
items += place_items(8, 56, 4, 3) # hihat intro (8th notes)
|
|
||||||
|
|
||||||
print(f"\nArrangement: {len(items)} items placed")
|
|
||||||
|
|
||||||
# ── melodic tracks ─────────────────────────────────────────────────────────────
|
|
||||||
drum_pattern_count = len(patterns) # 8
|
|
||||||
|
|
||||||
melodic_tracks = [
|
|
||||||
MelodicTrack(role="bass", sample_path=ch17_bass_path, notes=bass_notes, channel_index=17, volume=0.85, pan=0.0),
|
|
||||||
MelodicTrack(role="lead", sample_path=ch18_lead_path, notes=lead_notes, channel_index=18, volume=0.75, pan=0.15),
|
|
||||||
MelodicTrack(role="pad", sample_path=ch19_pad_path, notes=pad_notes, channel_index=19, volume=0.55, pan=0.0),
|
|
||||||
MelodicTrack(role="pluck", sample_path=ch20_pluck_path, notes=pluck_notes, channel_index=20, volume=0.65, pan=-0.1),
|
|
||||||
]
|
|
||||||
|
|
||||||
# samples dict for skeleton loader
|
|
||||||
samples = {
|
|
||||||
"channel10": ch10_perc,
|
|
||||||
"channel11": ch11_kick,
|
|
||||||
"channel12": ch12_snare,
|
|
||||||
"channel13": ch13_rim,
|
|
||||||
"channel14": ch14_perc2,
|
|
||||||
"channel15": ch15_hihat,
|
|
||||||
"channel16": ch16_clap,
|
|
||||||
}
|
|
||||||
|
|
||||||
song = SongDefinition(
|
|
||||||
meta=meta,
|
|
||||||
samples=samples,
|
|
||||||
patterns=patterns,
|
|
||||||
tracks=tracks,
|
|
||||||
items=items,
|
|
||||||
melodic_tracks=melodic_tracks,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── build ─────────────────────────────────────────────────────────────────────
|
|
||||||
print("\nValidating and building...")
|
|
||||||
errors = song.validate()
|
|
||||||
if errors:
|
|
||||||
print("VALIDATION ERRORS:")
|
|
||||||
for e in errors:
|
|
||||||
print(f" - {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
builder = FLPBuilder()
|
|
||||||
flp_bytes = builder.build(song)
|
|
||||||
|
|
||||||
out_path = PROJECT / "output" / "reggaeton_full.flp"
|
|
||||||
out_path.parent.mkdir(exist_ok=True)
|
|
||||||
out_path.write_bytes(flp_bytes)
|
|
||||||
|
|
||||||
size_kb = len(flp_bytes) / 1024
|
|
||||||
print(f"\nOK {out_path}")
|
|
||||||
print(f" {size_kb:.1f} KB -- {BARS_TOTAL} bars @ {BPM} BPM = {SONG_DURATION_M[0]}:{SONG_DURATION_M[1]:02d}")
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""compose_track.py — CLI para generar un .flp reggaeton completo desde cero."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Agregar project root al path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
||||||
|
|
||||||
from src.selector import SampleSelector
|
|
||||||
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
|
||||||
from src.composer.rhythm import get_notes
|
|
||||||
from src.flp_builder.schema import (
|
|
||||||
SongDefinition, SongMeta, PatternDef, ArrangementTrack,
|
|
||||||
ArrangementItemDef, MelodicTrack, MelodicNote
|
|
||||||
)
|
|
||||||
from src.flp_builder.builder import FLPBuilder
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Drum track configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DRUM_SAMPLE_KEYS = ["channel10", "channel11", "channel12", "channel13",
|
|
||||||
"channel14", "channel15", "channel16"]
|
|
||||||
DRUM_ROLES = ["perc", "kick", "snare", "rim",
|
|
||||||
"perc", "hihat", "clap"]
|
|
||||||
DRUM_CHANNELS = [10, 11, 12, 13,
|
|
||||||
14, 15, 16 ]
|
|
||||||
|
|
||||||
# Pattern definitions for drum tracks (1 pattern per relevant drum instrument)
|
|
||||||
DRUM_PATTERNS = [
|
|
||||||
# id, name, instrument, channel, generator
|
|
||||||
(1, "Kick Main", "kick", 11, "kick_main_notes"),
|
|
||||||
(2, "Snare Basic", "snare", 12, "snare_verse_notes"),
|
|
||||||
(3, "Hihat Straight","hihat", 15, "hihat_16th_notes"),
|
|
||||||
(4, "Clap On2and4", "clap", 16, "clap_24_notes"),
|
|
||||||
(5, "Perc Sparse", "perc", 10, "perc_combo_notes"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Melodic track configuration: (role, channel_index, volume, pan, generator_fn)
|
|
||||||
MELODIC_CONFIG = [
|
|
||||||
("bass", 17, 0.85, 0.0, bass_tresillo),
|
|
||||||
("lead", 18, 0.75, 0.1, lead_hook),
|
|
||||||
("pad", 19, 0.60, 0.0, pad_sustain),
|
|
||||||
("pluck", 20, 0.70, -0.15, lead_hook), # lead_hook with octave=5
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sampler template — extract once from reference FLP
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _ensure_sampler_template() -> Path:
|
|
||||||
"""Extract sampler channel template from reference FLP if not cached."""
|
|
||||||
project = Path(__file__).parents[1]
|
|
||||||
template_path = project / "output" / "flstudio_sampler_template.bin"
|
|
||||||
if template_path.exists():
|
|
||||||
return template_path
|
|
||||||
|
|
||||||
ref_flp = project / "my space ryt" / "my space ryt.flp"
|
|
||||||
ch11_path = project / "output" / "ch11_kick_template.bin"
|
|
||||||
|
|
||||||
# Try ch11_kick_template.bin first (legacy name)
|
|
||||||
if ch11_path.exists():
|
|
||||||
return ch11_path
|
|
||||||
|
|
||||||
# Extract channel 11 from reference FLP
|
|
||||||
from src.flp_builder.skeleton import ChannelSkeletonLoader
|
|
||||||
loader = ChannelSkeletonLoader(str(ref_flp), str(ch11_path), str(project / "output" / "samples"))
|
|
||||||
segments = loader._extract_channels_raw()
|
|
||||||
if 11 in segments:
|
|
||||||
template_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
template_path.write_bytes(segments[11])
|
|
||||||
print(f" [OK] Sampler template extracted -> {template_path}")
|
|
||||||
return template_path
|
|
||||||
|
|
||||||
raise FileNotFoundError(
|
|
||||||
"No sampler template found. "
|
|
||||||
"Please ensure output/ch11_kick_template.bin exists, "
|
|
||||||
"or the reference FLP contains channel 11."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_sample_path(sample: dict) -> str:
|
|
||||||
"""Build absolute path to a sample file.
|
|
||||||
|
|
||||||
The sample dict has ``original_path`` pointing to the source file.
|
|
||||||
We map it to the analyzed library path:
|
|
||||||
librerias/reggaeton/.../role/name.wav →
|
|
||||||
librerias/analyzed_samples/{role}/{new_name}
|
|
||||||
"""
|
|
||||||
role = sample.get("role", "")
|
|
||||||
new_name = sample.get("new_name", "")
|
|
||||||
project = Path(__file__).parents[1]
|
|
||||||
analyzed = project / "librerias" / "analyzed_samples" / role / new_name
|
|
||||||
if analyzed.exists():
|
|
||||||
return str(analyzed)
|
|
||||||
# Fallback: try original_path if it still exists
|
|
||||||
orig = sample.get("original_path", "")
|
|
||||||
if orig and Path(orig).exists():
|
|
||||||
return orig
|
|
||||||
# Last resort: return analyzed path even if missing (let FLPBuilder handle it)
|
|
||||||
return str(analyzed)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Genera un archivo .flp reggaeton completo con drums, bass, lead y pads."
|
|
||||||
)
|
|
||||||
parser.add_argument("--key", default="Am", help="Tonalidad (e.g. Am, Dm, Gm)")
|
|
||||||
parser.add_argument("--bpm", type=float, default=95, help="Tempo BPM")
|
|
||||||
parser.add_argument("--bars", type=int, default=8, help="Duración en bars")
|
|
||||||
parser.add_argument("--output", default="output/composed.flp", help="Ruta del .flp de salida")
|
|
||||||
parser.add_argument("--title", default="Reggaeton Track", help="Título del song")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Sample selection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
sel = SampleSelector()
|
|
||||||
samples: dict[str, str] = {}
|
|
||||||
|
|
||||||
for key_name, role, ch in zip(DRUM_SAMPLE_KEYS, DRUM_ROLES, DRUM_CHANNELS):
|
|
||||||
match = sel.select_one(role=role, bpm=args.bpm)
|
|
||||||
if match:
|
|
||||||
samples[key_name] = match["new_name"]
|
|
||||||
else:
|
|
||||||
samples[key_name] = f"{role}.wav"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Drum patterns
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
patterns: list[PatternDef] = []
|
|
||||||
for pid, name, instrument, channel, generator in DRUM_PATTERNS:
|
|
||||||
patterns.append(PatternDef(
|
|
||||||
id=pid,
|
|
||||||
name=name,
|
|
||||||
instrument=instrument,
|
|
||||||
channel=channel,
|
|
||||||
bars=args.bars,
|
|
||||||
generator=generator,
|
|
||||||
velocity_mult=1.0,
|
|
||||||
density=1.0,
|
|
||||||
))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Melodic tracks with sample selection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
melodic_tracks: list[MelodicTrack] = []
|
|
||||||
|
|
||||||
for role, ch_idx, vol, pan, generator_fn in MELODIC_CONFIG:
|
|
||||||
match = sel.select_one(role=role, key=args.key, bpm=args.bpm)
|
|
||||||
if match is None:
|
|
||||||
print(f" [WARN] No sample found for role '{role}', skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Build notes using the generator
|
|
||||||
if role == "pluck":
|
|
||||||
raw_notes = generator_fn(args.key, bars=args.bars, octave=5)
|
|
||||||
else:
|
|
||||||
raw_notes = generator_fn(args.key, bars=args.bars)
|
|
||||||
|
|
||||||
notes = [
|
|
||||||
MelodicNote(pos=n["pos"], len=n["len"], key=n["key"], vel=n["vel"])
|
|
||||||
for n in raw_notes
|
|
||||||
]
|
|
||||||
|
|
||||||
sample_path = _build_sample_path(match)
|
|
||||||
|
|
||||||
melodic_tracks.append(MelodicTrack(
|
|
||||||
role=role,
|
|
||||||
sample_path=sample_path,
|
|
||||||
notes=notes,
|
|
||||||
channel_index=ch_idx,
|
|
||||||
volume=vol,
|
|
||||||
pan=pan,
|
|
||||||
))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Arrangement tracks and items
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tracks: 1 drum track + 1 per melodic track
|
|
||||||
tracks: list[ArrangementTrack] = [
|
|
||||||
ArrangementTrack(index=1, name="Drums"),
|
|
||||||
]
|
|
||||||
for i, mt in enumerate(melodic_tracks):
|
|
||||||
tracks.append(ArrangementTrack(index=2 + i, name=mt.role.capitalize()))
|
|
||||||
|
|
||||||
# Items: each drum pattern placed at bar 0
|
|
||||||
items: list[ArrangementItemDef] = []
|
|
||||||
for p in patterns:
|
|
||||||
items.append(ArrangementItemDef(
|
|
||||||
pattern=p.id,
|
|
||||||
bar=0.0,
|
|
||||||
bars=float(args.bars),
|
|
||||||
track=1, # all drum patterns on the Drums track
|
|
||||||
muted=False,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Melodic items are added by FLPBuilder._build_arrangement (auto-added at bar 0)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Build and save
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
meta = SongMeta(
|
|
||||||
bpm=args.bpm,
|
|
||||||
key=args.key,
|
|
||||||
title=args.title,
|
|
||||||
ppq=96,
|
|
||||||
time_sig_num=4,
|
|
||||||
time_sig_den=4,
|
|
||||||
)
|
|
||||||
|
|
||||||
song = SongDefinition(
|
|
||||||
meta=meta,
|
|
||||||
samples=samples,
|
|
||||||
patterns=patterns,
|
|
||||||
tracks=tracks,
|
|
||||||
items=items,
|
|
||||||
melodic_tracks=melodic_tracks,
|
|
||||||
)
|
|
||||||
|
|
||||||
errors = song.validate()
|
|
||||||
if errors:
|
|
||||||
print("Validation errors:")
|
|
||||||
for e in errors:
|
|
||||||
print(f" - {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Ensure sampler template exists before building
|
|
||||||
template_path = _ensure_sampler_template()
|
|
||||||
project = Path(__file__).parents[1]
|
|
||||||
builder = FLPBuilder(
|
|
||||||
ref_flp=str(project / "my space ryt" / "my space ryt.flp"),
|
|
||||||
ch11_template=str(template_path),
|
|
||||||
samples_dir=str(project / "librerias" / "analyzed_samples"),
|
|
||||||
)
|
|
||||||
flp_bytes = builder.build(song)
|
|
||||||
|
|
||||||
out_path = Path(args.output)
|
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
out_path.write_bytes(flp_bytes)
|
|
||||||
|
|
||||||
print(f"[OK] FLP generado: {out_path} ({len(flp_bytes):,} bytes)")
|
|
||||||
print(f" Key: {args.key} | BPM: {args.bpm} | Bars: {args.bars}")
|
|
||||||
print(f" Patterns: {len(patterns)} drum + {len(melodic_tracks)} melodic")
|
|
||||||
print(f" Tracks: {len(tracks)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Inventory scanner - outputs JSON of all available resources."""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
|
|
||||||
from src.scanner import full_inventory
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
inv = full_inventory()
|
|
||||||
|
|
||||||
plugins = inv["plugins"]
|
|
||||||
summary = {
|
|
||||||
"generators": plugins["generator_names"],
|
|
||||||
"effects": plugins["effect_names"],
|
|
||||||
"total_generators": len(plugins["generators"]),
|
|
||||||
"total_effects": len(plugins["effects"]),
|
|
||||||
"sample_categories": {
|
|
||||||
k: len(v) for k, v in inv["samples"]["categories"].items()
|
|
||||||
},
|
|
||||||
"total_samples": inv["samples"]["total_files"],
|
|
||||||
"packs": [
|
|
||||||
{
|
|
||||||
"name": p["name"],
|
|
||||||
"audio": len(p["contents"].get("audio", [])),
|
|
||||||
"midi": len(p["contents"].get("midi", [])),
|
|
||||||
}
|
|
||||||
for p in inv["packs"]["packs"]
|
|
||||||
],
|
|
||||||
"vector_store": {
|
|
||||||
"total": inv["vector_store"]["total"],
|
|
||||||
"types": inv["vector_store"]["types"],
|
|
||||||
},
|
|
||||||
"organized_samples": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for cat, files in inv["samples"]["categories"].items():
|
|
||||||
summary["organized_samples"][cat] = [f["name"] for f in files[:20]]
|
|
||||||
|
|
||||||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
65
src/composer/converters.py
Normal file
65
src/composer/converters.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Converters — transform generator output to MIDI notes for SongDefinition.
|
||||||
|
|
||||||
|
rhythm generators → MidiNote list (channel → GM pitch mapping)
|
||||||
|
melodic generators → MidiNote list (note["key"] = pitch directly)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.core.schema import MidiNote
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GM drum pitch mapping — channels 10-16
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CHANNEL_PITCH: dict[int, int] = {
|
||||||
|
10: 39, # perc (General MIDI channel 10 = percussion)
|
||||||
|
11: 36, # kick
|
||||||
|
12: 38, # snare
|
||||||
|
13: 37, # rim
|
||||||
|
14: 50, # perc2
|
||||||
|
15: 42, # hihat
|
||||||
|
16: 39, # clap
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rhythm_to_midi(note_dict: dict[int, list[dict]]) -> list[MidiNote]:
|
||||||
|
"""Convert rhythm generator output (channel → note list) to MidiNote list.
|
||||||
|
|
||||||
|
note_dict: {channel: [{"pos", "len", "key", "vel"}, ...]}
|
||||||
|
- channel must be in CHANNEL_PITCH (10-16)
|
||||||
|
- pitch = CHANNEL_PITCH[channel]
|
||||||
|
- start = note["pos"]
|
||||||
|
- duration = note["len"]
|
||||||
|
- velocity = note["vel"]
|
||||||
|
"""
|
||||||
|
midi_notes: list[MidiNote] = []
|
||||||
|
for channel, notes in note_dict.items():
|
||||||
|
pitch = CHANNEL_PITCH.get(channel, 60)
|
||||||
|
for note in notes:
|
||||||
|
midi_notes.append(MidiNote(
|
||||||
|
pitch=pitch,
|
||||||
|
start=note["pos"],
|
||||||
|
duration=note["len"],
|
||||||
|
velocity=note["vel"],
|
||||||
|
))
|
||||||
|
return midi_notes
|
||||||
|
|
||||||
|
|
||||||
|
def melodic_to_midi(note_list: list[dict]) -> list[MidiNote]:
|
||||||
|
"""Convert melodic generator output (list of note dicts) to MidiNote list.
|
||||||
|
|
||||||
|
note_list: [{"pos", "len", "key", "vel"}, ...]
|
||||||
|
- pitch = note["key"] (directly used, not mapped)
|
||||||
|
- start = note["pos"]
|
||||||
|
- duration = note["len"]
|
||||||
|
- velocity = note["vel"]
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
MidiNote(
|
||||||
|
pitch=note["key"],
|
||||||
|
start=note["pos"],
|
||||||
|
duration=note["len"],
|
||||||
|
velocity=note["vel"],
|
||||||
|
)
|
||||||
|
for note in note_list
|
||||||
|
]
|
||||||
@@ -4,6 +4,8 @@ All generators return list[dict] with format {pos, len, key, vel}.
|
|||||||
Designed to feed MelodicTrack notes in SongDefinition.
|
Designed to feed MelodicTrack notes in SongDefinition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Scale definitions
|
# Scale definitions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -52,6 +54,18 @@ def _clamp_vel(v: int) -> int:
|
|||||||
return max(1, min(127, v))
|
return max(1, min(127, v))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_humanize(notes, humanize):
|
||||||
|
"""Apply humanization (velocity jitter + position nudge) to note list."""
|
||||||
|
if humanize <= 0:
|
||||||
|
return notes
|
||||||
|
jitter = humanize * 5
|
||||||
|
nudge = humanize * 0.03
|
||||||
|
for n in notes:
|
||||||
|
n["vel"] = _clamp_vel(int(n["vel"] + random.uniform(-jitter, jitter)))
|
||||||
|
n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge))
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Bass: tresillo
|
# Bass: tresillo
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -61,6 +75,7 @@ def bass_tresillo(
|
|||||||
bars: int,
|
bars: int,
|
||||||
octave: int = 3,
|
octave: int = 3,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
|
humanize: float = 0.0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Reggaeton tresillo bass pattern.
|
"""Reggaeton tresillo bass pattern.
|
||||||
|
|
||||||
@@ -90,7 +105,7 @@ def bass_tresillo(
|
|||||||
vel = _clamp_vel(int(vel * velocity_mult))
|
vel = _clamp_vel(int(vel * velocity_mult))
|
||||||
notes.append({"pos": o + pos, "len": 0.25, "key": key_note, "vel": vel})
|
notes.append({"pos": o + pos, "len": 0.25, "key": key_note, "vel": vel})
|
||||||
|
|
||||||
return notes
|
return _apply_humanize(notes, humanize)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -103,6 +118,7 @@ def lead_hook(
|
|||||||
octave: int = 5,
|
octave: int = 5,
|
||||||
density: float = 0.6,
|
density: float = 0.6,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
|
humanize: float = 0.0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Simple melodic hook over 4-8 bars.
|
"""Simple melodic hook over 4-8 bars.
|
||||||
|
|
||||||
@@ -154,7 +170,7 @@ def lead_hook(
|
|||||||
else:
|
else:
|
||||||
pos += 0.5
|
pos += 0.5
|
||||||
|
|
||||||
return notes
|
return _apply_humanize(notes, humanize)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -166,6 +182,7 @@ def chords_block(
|
|||||||
bars: int,
|
bars: int,
|
||||||
octave: int = 4,
|
octave: int = 4,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
|
humanize: float = 0.0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Blocked chords every 2 beats (half-bar).
|
"""Blocked chords every 2 beats (half-bar).
|
||||||
|
|
||||||
@@ -231,7 +248,7 @@ def chords_block(
|
|||||||
"vel": vel,
|
"vel": vel,
|
||||||
})
|
})
|
||||||
|
|
||||||
return notes
|
return _apply_humanize(notes, humanize)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -243,6 +260,7 @@ def pad_sustain(
|
|||||||
bars: int,
|
bars: int,
|
||||||
octave: int = 4,
|
octave: int = 4,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
|
humanize: float = 0.0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Long sustained pad notes, one per bar.
|
"""Long sustained pad notes, one per bar.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Reggaeton rhythm generators — pure functions returning note dicts per channel."""
|
"""Reggaeton rhythm generators — pure functions returning note dicts per channel."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Channel constants — match SAMPLE_MAP in channel_skeleton.py
|
# Channel constants — match SAMPLE_MAP in channel_skeleton.py
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -21,6 +23,20 @@ CH_CL = 16 # clap.wav
|
|||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _apply_groove(notes: list[dict], groove_strength: float) -> list[dict]:
|
||||||
|
"""Apply groove timing and velocity variations to notes.
|
||||||
|
|
||||||
|
groove_strength: 0.0 = no effect, 1.0 = maximum groove feel.
|
||||||
|
"""
|
||||||
|
if groove_strength <= 0:
|
||||||
|
return notes
|
||||||
|
jitter = 5 + groove_strength * 10
|
||||||
|
nudge = groove_strength * 0.02
|
||||||
|
for n in notes:
|
||||||
|
n["vel"] = max(1, min(127, n["vel"] + random.uniform(-jitter, jitter)))
|
||||||
|
n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge))
|
||||||
|
return notes
|
||||||
|
|
||||||
def _clamp_vel(vel: int) -> int:
|
def _clamp_vel(vel: int) -> int:
|
||||||
"""Clamp velocity to valid MIDI range [1, 127]."""
|
"""Clamp velocity to valid MIDI range [1, 127]."""
|
||||||
return max(1, min(127, vel))
|
return max(1, min(127, vel))
|
||||||
@@ -44,6 +60,7 @@ def kick_main_notes(
|
|||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105).
|
"""Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105).
|
||||||
|
|
||||||
@@ -55,13 +72,14 @@ def kick_main_notes(
|
|||||||
o = b * 4.0
|
o = b * 4.0
|
||||||
notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult)))
|
notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult)))
|
||||||
notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult)))
|
notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult)))
|
||||||
return {CH_K: notes}
|
return _apply_groove({CH_K: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
def kick_sparse_notes(
|
def kick_sparse_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Sparse intro/outro kick: just beat 1 per bar (vel 110).
|
"""Sparse intro/outro kick: just beat 1 per bar (vel 110).
|
||||||
|
|
||||||
@@ -71,20 +89,21 @@ def kick_sparse_notes(
|
|||||||
for b in range(bars):
|
for b in range(bars):
|
||||||
o = b * 4.0
|
o = b * 4.0
|
||||||
notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult)))
|
notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult)))
|
||||||
return {CH_K: notes}
|
return _apply_groove({CH_K: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
def kick_outro_notes(
|
def kick_outro_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Outro kick: dembow pattern with 0.75 baseline softness.
|
"""Outro kick: dembow pattern with 0.75 baseline softness.
|
||||||
|
|
||||||
Delegates to kick_main_notes with an additional 0.75 velocity scaling.
|
Delegates to kick_main_notes with an additional 0.75 velocity scaling.
|
||||||
Returns {CH_K: [notes...]}.
|
Returns {CH_K: [notes...]}.
|
||||||
"""
|
"""
|
||||||
return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density)
|
return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density, groove_strength=groove_strength)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -95,6 +114,7 @@ def snare_verse_notes(
|
|||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Reggaeton snare: beats 2, 3, 3-and, 4 per bar.
|
"""Reggaeton snare: beats 2, 3, 3-and, 4 per bar.
|
||||||
|
|
||||||
@@ -107,13 +127,14 @@ def snare_verse_notes(
|
|||||||
o = b * 4.0
|
o = b * 4.0
|
||||||
for p, v in _PATTERN:
|
for p, v in _PATTERN:
|
||||||
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
|
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
|
||||||
return {CH_S: notes}
|
return _apply_groove({CH_S: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
def snare_fill_notes(
|
def snare_fill_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Busier snare with 16th-note fills: adds positions 2.25 and 3.75.
|
"""Busier snare with 16th-note fills: adds positions 2.25 and 3.75.
|
||||||
|
|
||||||
@@ -133,20 +154,21 @@ def snare_fill_notes(
|
|||||||
o = b * 4.0
|
o = b * 4.0
|
||||||
for p, v in _PATTERN:
|
for p, v in _PATTERN:
|
||||||
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
|
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
|
||||||
return {CH_S: notes}
|
return _apply_groove({CH_S: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
def snare_outro_notes(
|
def snare_outro_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Softer outro snare (velocity_mult on top of 0.7 baseline).
|
"""Softer outro snare (velocity_mult on top of 0.7 baseline).
|
||||||
|
|
||||||
Delegates to snare_verse_notes with an additional 0.7 velocity scaling.
|
Delegates to snare_verse_notes with an additional 0.7 velocity scaling.
|
||||||
Returns {CH_S: [notes...]}.
|
Returns {CH_S: [notes...]}.
|
||||||
"""
|
"""
|
||||||
return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density)
|
return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density, groove_strength=groove_strength)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -157,6 +179,7 @@ def hihat_16th_notes(
|
|||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""16th-note hihat with three-tier accent mapping.
|
"""16th-note hihat with three-tier accent mapping.
|
||||||
|
|
||||||
@@ -177,13 +200,14 @@ def hihat_16th_notes(
|
|||||||
else: # 16th note position
|
else: # 16th note position
|
||||||
base_vel = 40
|
base_vel = 40
|
||||||
notes.append(_note(o + beat_frac, 0.1, _apply_vel(base_vel, velocity_mult)))
|
notes.append(_note(o + beat_frac, 0.1, _apply_vel(base_vel, velocity_mult)))
|
||||||
return {CH_H: notes}
|
return _apply_groove({CH_H: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
def hihat_8th_notes(
|
def hihat_8th_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""8th-note hihat for intro/breakdown.
|
"""8th-note hihat for intro/breakdown.
|
||||||
|
|
||||||
@@ -196,17 +220,14 @@ def hihat_8th_notes(
|
|||||||
for i in range(8):
|
for i in range(8):
|
||||||
base_vel = 70 if i % 2 == 0 else 50
|
base_vel = 70 if i % 2 == 0 else 50
|
||||||
notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult)))
|
notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult)))
|
||||||
return {CH_H: notes}
|
return _apply_groove({CH_H: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Clap generator
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def clap_24_notes(
|
def clap_24_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar.
|
"""Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar.
|
||||||
|
|
||||||
@@ -218,17 +239,14 @@ def clap_24_notes(
|
|||||||
o = b * 4.0
|
o = b * 4.0
|
||||||
notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult)))
|
notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult)))
|
||||||
notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult)))
|
notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult)))
|
||||||
return {CH_CL: notes}
|
return _apply_groove({CH_CL: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Percussion generators
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def perc_combo_notes(
|
def perc_combo_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Perc1 + Perc2 offbeat combo (tumba feel).
|
"""Perc1 + Perc2 offbeat combo (tumba feel).
|
||||||
|
|
||||||
@@ -244,13 +262,14 @@ def perc_combo_notes(
|
|||||||
p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult)))
|
p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult)))
|
||||||
p1_notes.append(_note(o + 1.5, 0.1, _apply_vel(70, velocity_mult)))
|
p1_notes.append(_note(o + 1.5, 0.1, _apply_vel(70, velocity_mult)))
|
||||||
p1_notes.append(_note(o + 3.5, 0.1, _apply_vel(65, velocity_mult)))
|
p1_notes.append(_note(o + 3.5, 0.1, _apply_vel(65, velocity_mult)))
|
||||||
return {CH_P1: p1_notes, CH_P2: p2_notes}
|
return _apply_groove({CH_P1: p1_notes, CH_P2: p2_notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
def rim_build_notes(
|
def rim_build_notes(
|
||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Rim roll that builds intensity across bars (4-bar cycle).
|
"""Rim roll that builds intensity across bars (4-bar cycle).
|
||||||
|
|
||||||
@@ -278,7 +297,7 @@ def rim_build_notes(
|
|||||||
vel = _apply_vel(base_vel, velocity_mult)
|
vel = _apply_vel(base_vel, velocity_mult)
|
||||||
for idx in _PATTERNS[cycle]:
|
for idx in _PATTERNS[cycle]:
|
||||||
notes.append(_note(o + idx * 0.25, 0.1, vel))
|
notes.append(_note(o + idx * 0.25, 0.1, vel))
|
||||||
return {CH_R: notes}
|
return _apply_groove({CH_R: notes}, groove_strength)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -305,7 +324,8 @@ def get_notes(
|
|||||||
bars: int,
|
bars: int,
|
||||||
velocity_mult: float = 1.0,
|
velocity_mult: float = 1.0,
|
||||||
density: float = 1.0,
|
density: float = 1.0,
|
||||||
|
groove_strength: float = 0.0,
|
||||||
) -> dict[int, list[dict]]:
|
) -> dict[int, list[dict]]:
|
||||||
"""Dispatch to the named generator. Raises KeyError if not found."""
|
"""Dispatch to the named generator. Raises KeyError if not found."""
|
||||||
gen = GENERATORS[generator_name]
|
gen = GENERATORS[generator_name]
|
||||||
return gen(bars, velocity_mult, density)
|
return gen(bars, velocity_mult, density, groove_strength)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import random
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
from ..flp_builder.schema import (
|
from ..core.schema import (
|
||||||
ArrangementItemDef,
|
ArrangementItemDef,
|
||||||
ArrangementTrack,
|
ArrangementTrack,
|
||||||
PatternDef,
|
PatternDef,
|
||||||
|
|||||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
253
src/core/schema.py
Normal file
253
src/core/schema.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""Core schema definitions for REAPER project generation.
|
||||||
|
|
||||||
|
Represents the intermediate representation (SongDefinition) used to build
|
||||||
|
REAPER .rpp files via RPPBuilder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Key validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
import re
|
||||||
|
|
||||||
|
_KEY_RE = re.compile(r"^[A-G][b#]?m?$")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dataclasses
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SongMeta:
|
||||||
|
"""Song metadata — tempo, key, time signature."""
|
||||||
|
|
||||||
|
bpm: float # 20–999
|
||||||
|
key: str # e.g. "Am", "Dm", "Gm"
|
||||||
|
title: str = "" # song title
|
||||||
|
ppq: int = 960 # ticks per quarter note (REAPER default)
|
||||||
|
time_sig_num: int = 4 # numerator e.g. 4
|
||||||
|
time_sig_den: int = 4 # denominator e.g. 4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MidiNote:
|
||||||
|
"""A single MIDI note event.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
pitch: MIDI note number 0–127 (60 = middle C)
|
||||||
|
start: Start time in beats (from start of item)
|
||||||
|
duration: Duration in beats
|
||||||
|
velocity: 0–127
|
||||||
|
"""
|
||||||
|
|
||||||
|
pitch: int
|
||||||
|
start: float # beats
|
||||||
|
duration: float # beats
|
||||||
|
velocity: int = 64
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArrangementTrack:
|
||||||
|
"""A track in the REAPER arrangement with index and display name."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArrangementItemDef:
|
||||||
|
"""An item placed in the arrangement referencing a pattern on a track.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
pattern: Pattern ID
|
||||||
|
bar: Start position in bars (float)
|
||||||
|
bars: Length in bars (float)
|
||||||
|
track: Track index
|
||||||
|
"""
|
||||||
|
|
||||||
|
pattern: int
|
||||||
|
bar: float
|
||||||
|
bars: float
|
||||||
|
track: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PatternDef:
|
||||||
|
"""A pattern definition with generator and variation axes.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique pattern ID
|
||||||
|
name: Display name (e.g. "Kick Main")
|
||||||
|
instrument: Sample/instrument key (e.g. "kick", "snare")
|
||||||
|
channel: MIDI channel (11 = kick, 12 = snare, etc.)
|
||||||
|
bars: Length in bars
|
||||||
|
generator: Generator function name
|
||||||
|
velocity_mult: Velocity multiplier (0.85–1.1)
|
||||||
|
density: Note density 0.0–1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
instrument: str
|
||||||
|
channel: int
|
||||||
|
bars: int
|
||||||
|
generator: str
|
||||||
|
velocity_mult: float = 1.0
|
||||||
|
density: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClipDef:
|
||||||
|
"""A clip placed on a track — either audio or MIDI.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
position: Start position in beats
|
||||||
|
length: Duration in beats
|
||||||
|
audio_path: Absolute path to audio file (for audio clips)
|
||||||
|
midi_notes: List of MIDI notes (for MIDI clips)
|
||||||
|
name: Display name
|
||||||
|
"""
|
||||||
|
|
||||||
|
position: float
|
||||||
|
length: float
|
||||||
|
name: str = ""
|
||||||
|
audio_path: str | None = None # for audio clips
|
||||||
|
midi_notes: list[MidiNote] = field(default_factory=list) # for MIDI clips
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_midi(self) -> bool:
|
||||||
|
return bool(self.midi_notes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_audio(self) -> bool:
|
||||||
|
return self.audio_path is not None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PluginDef:
|
||||||
|
"""A VST plugin instance on a track.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Display name (e.g. "Serum 2")
|
||||||
|
path: Plugin path/identifier (e.g. "VST3: Serum 2 (Xfer Records)")
|
||||||
|
index: Chain position (0 = first)
|
||||||
|
params: Optional dict of parameter index → value
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
index: int = 0
|
||||||
|
params: dict[int, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackDef:
|
||||||
|
"""A track in the REAPER project.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Track display name
|
||||||
|
volume: 0.0–1.0 (maps to REAPER volume fader)
|
||||||
|
pan: -1.0 to 1.0
|
||||||
|
color: REAPER color index (0–67), 0 = default
|
||||||
|
clips: Audio/MIDI clips placed on this track
|
||||||
|
plugins: VST plugins on this track
|
||||||
|
send_reverb: Reverb send level 0.0–1.0
|
||||||
|
send_delay: Delay send level 0.0–1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
volume: float = 0.85
|
||||||
|
pan: float = 0.0
|
||||||
|
color: int = 0
|
||||||
|
clips: list[ClipDef] = field(default_factory=list)
|
||||||
|
plugins: list[PluginDef] = field(default_factory=list)
|
||||||
|
send_reverb: float = 0.0
|
||||||
|
send_delay: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SongDefinition:
|
||||||
|
"""Complete song definition — the source of truth for one .rpp file.
|
||||||
|
|
||||||
|
This holds the minimal data needed by RPPBuilder to write a complete .rpp.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
meta: Song metadata (bpm, key, title, time signature)
|
||||||
|
tracks: List of REAPER tracks (TrackDef) with clips and plugins
|
||||||
|
patterns: Pattern definitions (PatternDef) for arrangement
|
||||||
|
items: Arrangement items (ArrangementItemDef) referencing patterns
|
||||||
|
progression_name: Chord progression name (e.g. "i-VII-VI-VII")
|
||||||
|
section_template: Section template name (default "standard")
|
||||||
|
samples: Sample file map (name → filename)
|
||||||
|
"""
|
||||||
|
|
||||||
|
meta: SongMeta
|
||||||
|
tracks: list[TrackDef] = field(default_factory=list)
|
||||||
|
patterns: list[PatternDef] = field(default_factory=list)
|
||||||
|
items: list[ArrangementItemDef] = field(default_factory=list)
|
||||||
|
progression_name: str = "i-VII-VI-VII"
|
||||||
|
section_template: str = "standard"
|
||||||
|
samples: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def validate(self) -> list[str]:
|
||||||
|
"""Return list of validation errors (empty list = valid)."""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# BPM range
|
||||||
|
if not (20 <= self.meta.bpm <= 999):
|
||||||
|
errors.append(f"meta.bpm must be 20–999, got {self.meta.bpm}")
|
||||||
|
|
||||||
|
# Key format
|
||||||
|
if not _KEY_RE.match(self.meta.key):
|
||||||
|
errors.append(f"meta.key must match ^[A-G][b#]?m?$, got '{self.meta.key}'")
|
||||||
|
|
||||||
|
# Track names unique
|
||||||
|
names = [t.name for t in self.tracks]
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
errors.append("Duplicate track names found")
|
||||||
|
|
||||||
|
# Check for clips with neither audio_path nor midi_notes
|
||||||
|
for ti, track in enumerate(self.tracks):
|
||||||
|
for ci, clip in enumerate(track.clips):
|
||||||
|
if not clip.is_audio and not clip.is_midi:
|
||||||
|
errors.append(
|
||||||
|
f"tracks[{ti}].clips[{ci}] has no audio_path and no midi_notes"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Computed helpers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length_beats(self) -> float:
|
||||||
|
"""Compute the total length in beats from all clips."""
|
||||||
|
if not self.tracks:
|
||||||
|
return 0.0
|
||||||
|
max_end = 0.0
|
||||||
|
for track in self.tracks:
|
||||||
|
for clip in track.clips:
|
||||||
|
end = clip.position + clip.length
|
||||||
|
if end > max_end:
|
||||||
|
max_end = end
|
||||||
|
return max_end
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Serialization
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def to_json(self, indent: int = 2) -> str:
|
||||||
|
"""Serialize to a JSON string."""
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict
|
||||||
|
return json.dumps(asdict(self), indent=indent, ensure_ascii=False)
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from .writer import FLPWriter
|
|
||||||
from .writer import FLPWriter
|
|
||||||
from .project import FLPProject, Note, Channel, Pattern, Plugin
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"FLPWriter",
|
|
||||||
"FLPProject",
|
|
||||||
"Note",
|
|
||||||
"Channel",
|
|
||||||
"Pattern",
|
|
||||||
"Plugin",
|
|
||||||
]
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
"""FL Studio arrangement/playlist encoding.
|
|
||||||
|
|
||||||
Encodes playlist items (ID233) and track data (ID238) into binary format
|
|
||||||
matching FL Studio's internal structure. Extracted from the proven v15 builder
|
|
||||||
(output/build_reggaeton_v15.py, lines 61-90).
|
|
||||||
|
|
||||||
Arrangement block sequence:
|
|
||||||
ArrNew(99) → ArrName(241) → Flag36(36) → Playlist(233)
|
|
||||||
→ TrackData(238)×N → ArrCurrent(100)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from .events import encode_byte_event, encode_data_event, encode_word_event
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Constants
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
PPQ_DEFAULT: int = 96
|
|
||||||
MAX_TRACKS_DEFAULT: int = 500
|
|
||||||
PATTERN_BASE: int = 20480
|
|
||||||
|
|
||||||
# Arrangement event IDs (not yet in EventID enum — raw constants)
|
|
||||||
EID_ARR_NEW = 99
|
|
||||||
EID_ARR_CURRENT = 100
|
|
||||||
EID_ARR_NAME = 241
|
|
||||||
EID_FLAG_36 = 36
|
|
||||||
EID_PLAYLIST = 233
|
|
||||||
EID_TRACK_DATA = 238
|
|
||||||
|
|
||||||
# TrackData template size (bytes), extracted from reference FLP
|
|
||||||
TRACK_DATA_SIZE = 66
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ArrangementItem dataclass
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ArrangementItem:
|
|
||||||
"""A single playlist item placed on the arrangement timeline.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pattern_id: Pattern number (1-based).
|
|
||||||
bar: Start bar (0-based, fractional allowed).
|
|
||||||
num_bars: Length in bars (fractional allowed).
|
|
||||||
track_index: Track row index (0-based).
|
|
||||||
muted: Whether the item is muted in the playlist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pattern_id: int # pattern number (1-based)
|
|
||||||
bar: float # start bar (0-based)
|
|
||||||
num_bars: float # length in bars
|
|
||||||
track_index: int # 0-based track index
|
|
||||||
muted: bool = False
|
|
||||||
|
|
||||||
def to_bytes(
|
|
||||||
self,
|
|
||||||
ppq: int = PPQ_DEFAULT,
|
|
||||||
max_tracks: int = MAX_TRACKS_DEFAULT,
|
|
||||||
) -> bytes:
|
|
||||||
"""Encode as a 32-byte playlist item (ID233 format).
|
|
||||||
|
|
||||||
Encoding rules (from reverse-engineered FL Studio format):
|
|
||||||
position = int(bar × ppq × 4) — ticks, truncated
|
|
||||||
pattern_base = 20480 — constant
|
|
||||||
item_index = 20480 + pattern_id
|
|
||||||
length = int(num_bars × ppq × 4) — ticks, truncated
|
|
||||||
track_rvidx = (max_tracks - 1) - track_index — REVERSED
|
|
||||||
flags = 0x2040 if muted else 0x0040
|
|
||||||
"""
|
|
||||||
position = int(self.bar * ppq * 4)
|
|
||||||
item_index = PATTERN_BASE + self.pattern_id
|
|
||||||
length = int(self.num_bars * ppq * 4)
|
|
||||||
track_rvidx = (max_tracks - 1) - self.track_index
|
|
||||||
flags = 0x2040 if self.muted else 0x0040
|
|
||||||
|
|
||||||
return struct.pack(
|
|
||||||
"<IHHIHH HH 4B ff",
|
|
||||||
position,
|
|
||||||
PATTERN_BASE,
|
|
||||||
item_index,
|
|
||||||
length,
|
|
||||||
track_rvidx,
|
|
||||||
0, # group
|
|
||||||
0x0078,
|
|
||||||
flags,
|
|
||||||
64, 100, 128, 128,
|
|
||||||
-1.0, -1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TrackData helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_track_data_template(reference_flp_bytes: bytes) -> bytes:
|
|
||||||
"""Extract the 66-byte TrackData template from a reference FLP.
|
|
||||||
|
|
||||||
Scans the raw FLP bytes for the first ID238 event and returns its
|
|
||||||
66-byte payload. This template is then cloned and patched for each
|
|
||||||
of the *max_tracks* track data entries in the arrangement section.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
reference_flp_bytes: Full contents of a valid .flp file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The 66-byte track-data template.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If no ID238 event of the expected size is found.
|
|
||||||
"""
|
|
||||||
pos = 22 # skip FLhd (14 bytes) + FLdt header (8 bytes)
|
|
||||||
|
|
||||||
while pos < len(reference_flp_bytes):
|
|
||||||
ib = reference_flp_bytes[pos]
|
|
||||||
pos += 1
|
|
||||||
|
|
||||||
if ib < 64:
|
|
||||||
# Byte event: 1-byte value
|
|
||||||
pos += 1
|
|
||||||
elif ib < 128:
|
|
||||||
# Word event: 2-byte value
|
|
||||||
pos += 2
|
|
||||||
elif ib < 192:
|
|
||||||
# Dword event: 4-byte value
|
|
||||||
pos += 4
|
|
||||||
else:
|
|
||||||
# Data / text event: varint length + payload
|
|
||||||
size = 0
|
|
||||||
shift = 0
|
|
||||||
while True:
|
|
||||||
b = reference_flp_bytes[pos]
|
|
||||||
pos += 1
|
|
||||||
size |= (b & 0x7F) << shift
|
|
||||||
shift += 7
|
|
||||||
if not (b & 0x80):
|
|
||||||
break
|
|
||||||
|
|
||||||
if ib == EID_TRACK_DATA and size == TRACK_DATA_SIZE:
|
|
||||||
return bytes(reference_flp_bytes[pos:pos + size])
|
|
||||||
|
|
||||||
pos += size
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
f"No ID{EID_TRACK_DATA} TrackData event ({TRACK_DATA_SIZE} bytes) "
|
|
||||||
"found in reference FLP"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_track_data(iid: int, enabled: int, template: bytes) -> bytes:
|
|
||||||
"""Clone *template*, patch iid at byte 0 (uint32 LE) and enabled at byte 12.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
iid: Internal track ID (sequential from 1).
|
|
||||||
enabled: 0 = disabled, 1 = enabled.
|
|
||||||
template: 66-byte template extracted by :func:`build_track_data_template`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
66-byte patched track data.
|
|
||||||
"""
|
|
||||||
td = bytearray(template)
|
|
||||||
struct.pack_into("<I", td, 0, iid)
|
|
||||||
td[12] = enabled & 0xFF
|
|
||||||
return bytes(td)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Full arrangement section builder
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_arrangement_section(
|
|
||||||
items: list[ArrangementItem],
|
|
||||||
track_data_template: bytes,
|
|
||||||
ppq: int = PPQ_DEFAULT,
|
|
||||||
max_tracks: int = MAX_TRACKS_DEFAULT,
|
|
||||||
) -> bytes:
|
|
||||||
"""Build the full post-channel arrangement section bytes.
|
|
||||||
|
|
||||||
Produces the exact byte sequence FL Studio expects after the channel
|
|
||||||
events:
|
|
||||||
|
|
||||||
ArrNew(99) → ArrName(241) → Flag36(36) → Playlist(233)
|
|
||||||
→ TrackData(238) × *max_tracks* → ArrCurrent(100)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
items: Playlist items to encode.
|
|
||||||
track_data_template: 66-byte template from :func:`build_track_data_template`.
|
|
||||||
ppq: Pulses-per-quarter-note (default 96).
|
|
||||||
max_tracks: Total track-data entries to write (default 500).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complete arrangement section as raw bytes.
|
|
||||||
"""
|
|
||||||
result = bytearray()
|
|
||||||
|
|
||||||
# 1. ArrNew — word event, value = 0
|
|
||||||
result.extend(encode_word_event(EID_ARR_NEW, 0))
|
|
||||||
|
|
||||||
# 2. ArrName — "Arrangement" as UTF-16-LE + null terminator
|
|
||||||
arr_name = "Arrangement".encode("utf-16-le") + b"\x00\x00"
|
|
||||||
result.extend(encode_data_event(EID_ARR_NAME, arr_name))
|
|
||||||
|
|
||||||
# 3. Flag36 — byte event, value = 0
|
|
||||||
result.extend(encode_byte_event(EID_FLAG_36, 0))
|
|
||||||
|
|
||||||
# 4. Playlist — data event, concatenation of all 32-byte items
|
|
||||||
pl_data = b"".join(item.to_bytes(ppq, max_tracks) for item in items)
|
|
||||||
result.extend(encode_data_event(EID_PLAYLIST, pl_data))
|
|
||||||
|
|
||||||
# 5. TrackData × max_tracks — first track (iid=1) disabled, rest enabled
|
|
||||||
for i in range(1, max_tracks + 1):
|
|
||||||
enabled = 0 if i == 1 else 1
|
|
||||||
td = encode_track_data(i, enabled, track_data_template)
|
|
||||||
result.extend(encode_data_event(EID_TRACK_DATA, td))
|
|
||||||
|
|
||||||
# 6. ArrCurrent — word event, value = 0
|
|
||||||
result.extend(encode_word_event(EID_ARR_CURRENT, 0))
|
|
||||||
|
|
||||||
return bytes(result)
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
"""JSON->FLP builder - converts SongDefinition to a valid FL Studio FLP file.
|
|
||||||
|
|
||||||
Replicates the proven assembly logic from ``output/build_reggaeton_v15.py`` but
|
|
||||||
driven entirely by a :class:`SongDefinition` object instead of hardcoded values.
|
|
||||||
|
|
||||||
Assembly order (matches v15):
|
|
||||||
FLhd header + FLdt wrapper around:
|
|
||||||
header_events + pattern_events + channel_events + arrangement_events
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
builder = FLPBuilder()
|
|
||||||
flp_bytes = builder.build(song)
|
|
||||||
Path("out.flp").write_bytes(flp_bytes)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import struct
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .schema import SongDefinition, PatternDef, MelodicTrack
|
|
||||||
from .skeleton import ChannelSkeletonLoader
|
|
||||||
from .arrangement import ArrangementItem, build_arrangement_section, build_track_data_template
|
|
||||||
from .events import (
|
|
||||||
EventID,
|
|
||||||
encode_text_event,
|
|
||||||
encode_word_event,
|
|
||||||
encode_data_event,
|
|
||||||
encode_notes_block,
|
|
||||||
)
|
|
||||||
from ..composer.rhythm import get_notes
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default paths (relative to project root)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
REF_FLP = Path(__file__).parents[2] / "my space ryt" / "my space ryt.flp"
|
|
||||||
CH11_TMPL = Path(__file__).parents[2] / "output" / "ch11_kick_template.bin"
|
|
||||||
SAMPLES = Path(__file__).parents[2] / "output" / "samples"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Note format conversion
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _convert_rhythm_notes(notes: list[dict]) -> list[dict]:
|
|
||||||
"""Convert rhythm.py note format to events.py format.
|
|
||||||
|
|
||||||
rhythm.py: ``{"pos", "len", "key", "vel"}``
|
|
||||||
events.py: ``{"position", "length", "key", "velocity"}``
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
{"position": n["pos"], "length": n["len"], "key": n["key"], "velocity": n["vel"]}
|
|
||||||
for n in notes
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_melodic_notes(notes: list) -> list[dict]:
|
|
||||||
"""Convert MelodicNote (pos/len/key/vel) to events.py format.
|
|
||||||
|
|
||||||
MelodicNote: ``{pos, len, key, vel}``
|
|
||||||
events.py: ``{"position", "length", "key", "velocity"}``
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
{"position": n.pos, "length": n.len, "key": n.key, "velocity": n.vel}
|
|
||||||
for n in notes
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# FLPBuilder
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class FLPBuilder:
|
|
||||||
"""Builds an FLP binary from a :class:`SongDefinition`.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
ref_flp:
|
|
||||||
Path to a reference FLP used for header events and channel skeleton.
|
|
||||||
ch11_template:
|
|
||||||
Path to the ch11_kick_template.bin for empty sampler channels.
|
|
||||||
samples_dir:
|
|
||||||
Directory containing .wav sample files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ref_flp: str | Path = REF_FLP,
|
|
||||||
ch11_template: str | Path = CH11_TMPL,
|
|
||||||
samples_dir: str | Path = SAMPLES,
|
|
||||||
):
|
|
||||||
self._ref_flp = Path(ref_flp)
|
|
||||||
self._ch11 = Path(ch11_template)
|
|
||||||
self._samples = Path(samples_dir)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build(self, song: SongDefinition) -> bytes:
|
|
||||||
"""Convert *song* to raw FLP bytes.
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
If song validation fails or the reference FLP is malformed.
|
|
||||||
FileNotFoundError
|
|
||||||
If reference FLP or templates are missing.
|
|
||||||
"""
|
|
||||||
# 1. Validate
|
|
||||||
errors = song.validate()
|
|
||||||
if errors:
|
|
||||||
raise ValueError(
|
|
||||||
"Song validation failed:\n - " + "\n - ".join(errors)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Read reference FLP
|
|
||||||
ref_bytes = self._ref_flp.read_bytes()
|
|
||||||
num_channels = struct.unpack("<H", ref_bytes[10:12])[0]
|
|
||||||
|
|
||||||
# 3. Build each section
|
|
||||||
header_bytes = self._build_header(song, ref_bytes)
|
|
||||||
pattern_bytes = self._build_all_patterns(song)
|
|
||||||
|
|
||||||
# 3b. Build melodic map and melodic pattern bytes
|
|
||||||
melodic_map: dict[int, tuple[str, str]] = {}
|
|
||||||
melodic_pattern_bytes = b""
|
|
||||||
if song.melodic_tracks:
|
|
||||||
for mt in song.melodic_tracks:
|
|
||||||
wav_dir = str(Path(mt.sample_path).parent)
|
|
||||||
wav_name = Path(mt.sample_path).name
|
|
||||||
melodic_map[mt.channel_index] = (wav_dir, wav_name)
|
|
||||||
|
|
||||||
# Assign pattern IDs after drum patterns (1-based)
|
|
||||||
drum_pattern_count = len(song.patterns)
|
|
||||||
for i, mt in enumerate(song.melodic_tracks):
|
|
||||||
pattern_id = drum_pattern_count + i + 1
|
|
||||||
melodic_pattern_bytes += self._build_melodic_pattern(
|
|
||||||
mt, pattern_id, song.meta.ppq
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# No melodic tracks: melodic_map stays empty, same as before
|
|
||||||
pass
|
|
||||||
|
|
||||||
loader = ChannelSkeletonLoader(
|
|
||||||
str(self._ref_flp),
|
|
||||||
str(self._ch11),
|
|
||||||
str(self._samples),
|
|
||||||
)
|
|
||||||
channel_bytes = loader.load(song.samples, melodic_map=melodic_map)
|
|
||||||
|
|
||||||
track_data_template = build_track_data_template(ref_bytes)
|
|
||||||
arrangement_bytes = self._build_arrangement(song, track_data_template)
|
|
||||||
|
|
||||||
# 4. Assemble body: header + patterns + melodic_patterns + channels + arrangement
|
|
||||||
body = (
|
|
||||||
header_bytes
|
|
||||||
+ pattern_bytes
|
|
||||||
+ melodic_pattern_bytes
|
|
||||||
+ channel_bytes
|
|
||||||
+ arrangement_bytes
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Wrap with FLhd + FLdt headers (matches v15 line 317-318)
|
|
||||||
flp = (
|
|
||||||
struct.pack("<4sIhHH", b"FLhd", 6, 0, num_channels, song.meta.ppq)
|
|
||||||
+ b"FLdt"
|
|
||||||
+ struct.pack("<I", len(body))
|
|
||||||
+ body
|
|
||||||
)
|
|
||||||
|
|
||||||
return flp
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Header
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_header(self, song: SongDefinition, ref_bytes: bytes) -> bytes:
|
|
||||||
"""Extract header events from reference FLP and patch with song.meta values.
|
|
||||||
|
|
||||||
The "header" is everything between offset 22 (after FLhd+FLdt chunk
|
|
||||||
headers) and the first ``PatNew`` event. This includes version info,
|
|
||||||
tempo, time-signature, etc. We patch the tempo (BPM) to match the
|
|
||||||
song definition.
|
|
||||||
|
|
||||||
This replicates v15 lines 133-141.
|
|
||||||
"""
|
|
||||||
# Find first PatNew event
|
|
||||||
first_pat = self._find_first_event(ref_bytes, EventID.PatNew)
|
|
||||||
if first_pat is None:
|
|
||||||
raise ValueError("No PatNew event found in reference FLP")
|
|
||||||
|
|
||||||
# Extract header events (everything before first pattern)
|
|
||||||
header = bytearray(ref_bytes[22:first_pat])
|
|
||||||
|
|
||||||
# Patch BPM — Tempo event (ID 156) is a dword, value = BPM * 1000
|
|
||||||
p = 0
|
|
||||||
while p < len(header):
|
|
||||||
np, _, ib, _v, _vt = self._read_ev(bytes(header), p)
|
|
||||||
if ib == EventID.Tempo:
|
|
||||||
struct.pack_into("<I", header, p + 1, int(song.meta.bpm * 1000))
|
|
||||||
break
|
|
||||||
p = np
|
|
||||||
|
|
||||||
return bytes(header)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Patterns
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_pattern_bytes(self, pattern: PatternDef, ppq: int) -> bytes:
|
|
||||||
"""Build all FLP events for one pattern.
|
|
||||||
|
|
||||||
Sequence:
|
|
||||||
1. ``PatNew`` (word event) — value = pattern.id - 1 (0-based)
|
|
||||||
2. ``PatName`` (text event) — UTF-16-LE pattern name
|
|
||||||
3. ``PatNotes`` (data event) per channel from ``get_notes()``
|
|
||||||
|
|
||||||
Returns raw bytes for this pattern.
|
|
||||||
"""
|
|
||||||
buf = bytearray()
|
|
||||||
|
|
||||||
# 1. PatNew — word event, 0-based index
|
|
||||||
buf += encode_word_event(EventID.PatNew, pattern.id - 1)
|
|
||||||
|
|
||||||
# 2. PatName — text event (UTF-16-LE + null terminator)
|
|
||||||
if pattern.name:
|
|
||||||
buf += encode_text_event(EventID.PatName, pattern.name)
|
|
||||||
|
|
||||||
# 3. Generate notes via rhythm.py dispatcher
|
|
||||||
notes_by_channel = get_notes(
|
|
||||||
pattern.generator,
|
|
||||||
pattern.bars,
|
|
||||||
pattern.velocity_mult,
|
|
||||||
pattern.density,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Encode notes for each channel
|
|
||||||
for ch_idx, raw_notes in notes_by_channel.items():
|
|
||||||
converted = _convert_rhythm_notes(raw_notes)
|
|
||||||
buf += encode_data_event(
|
|
||||||
EventID.PatNotes,
|
|
||||||
encode_notes_block(ch_idx, converted, ppq),
|
|
||||||
)
|
|
||||||
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
def _build_all_patterns(self, song: SongDefinition) -> bytes:
|
|
||||||
"""Build bytes for all patterns in *song.patterns*."""
|
|
||||||
buf = bytearray()
|
|
||||||
for pattern in song.patterns:
|
|
||||||
buf += self._build_pattern_bytes(pattern, song.meta.ppq)
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
def _build_melodic_pattern(
|
|
||||||
self, mt: MelodicTrack, pattern_id: int, ppq: int
|
|
||||||
) -> bytes:
|
|
||||||
"""Build FLP events for one melodic track pattern.
|
|
||||||
|
|
||||||
Sequence:
|
|
||||||
1. ``PatNew`` (word event) — value = pattern_id - 1 (0-based)
|
|
||||||
2. ``PatName`` (text event) — UTF-16-LE with ``mt.role`` as name
|
|
||||||
3. ``PatNotes`` (data event) with notes for the melodic channel
|
|
||||||
|
|
||||||
Returns raw bytes for this melodic pattern.
|
|
||||||
"""
|
|
||||||
buf = bytearray()
|
|
||||||
|
|
||||||
# 1. PatNew — word event, 0-based index
|
|
||||||
buf += encode_word_event(EventID.PatNew, pattern_id - 1)
|
|
||||||
|
|
||||||
# 2. PatName — text event (UTF-16-LE + null terminator)
|
|
||||||
if mt.role:
|
|
||||||
buf += encode_text_event(EventID.PatName, mt.role)
|
|
||||||
|
|
||||||
# 3. Convert MelodicNotes to events.py format and encode
|
|
||||||
converted = _convert_melodic_notes(mt.notes)
|
|
||||||
buf += encode_data_event(
|
|
||||||
EventID.PatNotes,
|
|
||||||
encode_notes_block(mt.channel_index, converted, ppq),
|
|
||||||
)
|
|
||||||
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Arrangement
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_arrangement(
|
|
||||||
self, song: SongDefinition, track_data_template: bytes
|
|
||||||
) -> bytes:
|
|
||||||
"""Convert *song.items* to arrangement section bytes.
|
|
||||||
|
|
||||||
Each :class:`ArrangementItemDef` (1-based track) is converted to an
|
|
||||||
:class:`ArrangementItem` (0-based track_index) and fed to
|
|
||||||
:func:`build_arrangement_section`.
|
|
||||||
"""
|
|
||||||
items = [
|
|
||||||
ArrangementItem(
|
|
||||||
pattern_id=item.pattern,
|
|
||||||
bar=item.bar,
|
|
||||||
num_bars=item.bars,
|
|
||||||
track_index=item.track - 1, # 1-based -> 0-based
|
|
||||||
muted=item.muted,
|
|
||||||
)
|
|
||||||
for item in song.items
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add melodic track items after drum items
|
|
||||||
if song.melodic_tracks:
|
|
||||||
drum_pattern_count = len(song.patterns)
|
|
||||||
# Determine starting track index (after drum tracks)
|
|
||||||
max_drum_track = max((item.track for item in song.items), default=1)
|
|
||||||
for i, mt in enumerate(song.melodic_tracks):
|
|
||||||
pattern_id = drum_pattern_count + i + 1
|
|
||||||
track_index = max_drum_track + i # 0-based, after drum tracks
|
|
||||||
items.append(
|
|
||||||
ArrangementItem(
|
|
||||||
pattern_id=pattern_id,
|
|
||||||
bar=0,
|
|
||||||
num_bars=4, # default 4 bars
|
|
||||||
track_index=track_index,
|
|
||||||
muted=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return build_arrangement_section(
|
|
||||||
items,
|
|
||||||
track_data_template,
|
|
||||||
ppq=song.meta.ppq,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Event parsing helpers (minimal, for header scanning)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_ev(data: bytes, pos: int) -> tuple:
|
|
||||||
"""Read one FLP event from *data* starting at *pos*.
|
|
||||||
|
|
||||||
Returns ``(next_pos, start, event_id, value, value_type)``.
|
|
||||||
"""
|
|
||||||
start = pos
|
|
||||||
ib = data[pos]
|
|
||||||
pos += 1
|
|
||||||
|
|
||||||
if ib < 64:
|
|
||||||
# Byte event: 1 byte ID + 1 byte value
|
|
||||||
return pos + 1, start, ib, data[start + 1], "byte"
|
|
||||||
elif ib < 128:
|
|
||||||
# Word event: 1 byte ID + 2 byte value
|
|
||||||
return pos + 2, start, ib, struct.unpack("<H", data[pos : pos + 2])[0], "word"
|
|
||||||
elif ib < 192:
|
|
||||||
# Dword event: 1 byte ID + 4 byte value
|
|
||||||
return pos + 4, start, ib, struct.unpack("<I", data[pos : pos + 4])[0], "dword"
|
|
||||||
else:
|
|
||||||
# Data/text event: 1 byte ID + varint size + payload
|
|
||||||
sz = 0
|
|
||||||
sh = 0
|
|
||||||
while True:
|
|
||||||
b = data[pos]
|
|
||||||
pos += 1
|
|
||||||
sz |= (b & 0x7F) << sh
|
|
||||||
sh += 7
|
|
||||||
if not (b & 0x80):
|
|
||||||
break
|
|
||||||
return pos + sz, start, ib, data[pos : pos + sz], "data"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _find_first_event(cls, data: bytes, event_id: int) -> int | None:
|
|
||||||
"""Find the byte offset of the first occurrence of *event_id*.
|
|
||||||
|
|
||||||
Starts scanning at offset 22 (past FLhd + FLdt chunk headers).
|
|
||||||
Returns ``None`` if the event is not found.
|
|
||||||
"""
|
|
||||||
pos = 22
|
|
||||||
while pos < len(data):
|
|
||||||
np, start, ib, _val, _vt = cls._read_ev(data, pos)
|
|
||||||
if ib == event_id:
|
|
||||||
return start
|
|
||||||
pos = np
|
|
||||||
return None
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import struct
|
|
||||||
from enum import IntEnum
|
|
||||||
|
|
||||||
|
|
||||||
class EventID(IntEnum):
|
|
||||||
WORD = 64
|
|
||||||
DWORD = 128
|
|
||||||
TEXT = 192
|
|
||||||
DATA = 208
|
|
||||||
|
|
||||||
LoopActive = 9
|
|
||||||
ShowInfo = 10
|
|
||||||
Volume = 12
|
|
||||||
PanLaw = 23
|
|
||||||
Licensed = 28
|
|
||||||
TempoCoarse = 66
|
|
||||||
Pitch = 80
|
|
||||||
TempoFine = 93
|
|
||||||
CurGroupId = 146
|
|
||||||
Tempo = 156
|
|
||||||
FLBuild = 159
|
|
||||||
Title = 194
|
|
||||||
Comments = 195
|
|
||||||
Url = 197
|
|
||||||
RTFComments = 198
|
|
||||||
FLVersion = 199
|
|
||||||
Licensee = 200
|
|
||||||
DataPath = 202
|
|
||||||
Genre = 206
|
|
||||||
Artists = 207
|
|
||||||
Timestamp = 237
|
|
||||||
|
|
||||||
ChIsEnabled = 0
|
|
||||||
ChVolByte = 2
|
|
||||||
ChPanByte = 3
|
|
||||||
ChZipped = 15
|
|
||||||
ChType = 21
|
|
||||||
ChRoutedTo = 22
|
|
||||||
ChIsLocked = 32
|
|
||||||
ChNew = 64
|
|
||||||
ChFreqTilt = 69
|
|
||||||
ChFXFlags = 70
|
|
||||||
ChCutoff = 71
|
|
||||||
ChVolWord = 72
|
|
||||||
ChPanWord = 73
|
|
||||||
ChPreamp = 74
|
|
||||||
ChFadeOut = 75
|
|
||||||
ChFadeIn = 76
|
|
||||||
ChResonance = 83
|
|
||||||
ChStereoDelay = 85
|
|
||||||
ChPogo = 86
|
|
||||||
ChTimeShift = 89
|
|
||||||
ChChildren = 94
|
|
||||||
ChSwing = 97
|
|
||||||
ChRingMod = 131
|
|
||||||
ChCutGroup = 132
|
|
||||||
ChRootNote = 135
|
|
||||||
ChDelayModXY = 138
|
|
||||||
ChReverb = 139
|
|
||||||
ChStretchTime = 140
|
|
||||||
ChFineTune = 142
|
|
||||||
ChSamplerFlags = 143
|
|
||||||
ChLayerFlags = 144
|
|
||||||
ChGroupNum = 145
|
|
||||||
ChAUSampleRate = 153
|
|
||||||
ChName = 192
|
|
||||||
ChSamplePath = 196
|
|
||||||
ChDelay = 209
|
|
||||||
ChParameters = 215
|
|
||||||
ChEnvelopeLFO = 218
|
|
||||||
ChLevels = 219
|
|
||||||
ChPolyphony = 221
|
|
||||||
ChTracking = 228
|
|
||||||
ChLevelAdjusts = 229
|
|
||||||
ChAutomation = 234
|
|
||||||
|
|
||||||
PatLooped = 26
|
|
||||||
PatNew = 65
|
|
||||||
PatColor = 150
|
|
||||||
PatName = 193
|
|
||||||
PatChannelIID = 160
|
|
||||||
PatLength = 164
|
|
||||||
PatControllers = 223
|
|
||||||
PatNotes = 224
|
|
||||||
|
|
||||||
PluginColor = 128
|
|
||||||
PluginIcon = 155
|
|
||||||
PluginInternalName = 201
|
|
||||||
PluginName = 203
|
|
||||||
PluginWrapper = 212
|
|
||||||
PluginData = 213
|
|
||||||
|
|
||||||
MixerAPDC = 29
|
|
||||||
MixerParams = 225
|
|
||||||
|
|
||||||
|
|
||||||
def encode_varint(value: int) -> bytes:
|
|
||||||
result = bytearray()
|
|
||||||
while True:
|
|
||||||
byte = value & 0x7F
|
|
||||||
value >>= 7
|
|
||||||
if value:
|
|
||||||
byte |= 0x80
|
|
||||||
result.append(byte)
|
|
||||||
if not value:
|
|
||||||
break
|
|
||||||
return bytes(result)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_text(text: str, utf16: bool = True) -> bytes:
|
|
||||||
if utf16:
|
|
||||||
return text.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
return text.encode("ascii") + b"\x00"
|
|
||||||
|
|
||||||
|
|
||||||
def encode_byte_event(id_: int, value: int) -> bytes:
|
|
||||||
return bytes([id_, value & 0xFF])
|
|
||||||
|
|
||||||
|
|
||||||
def encode_word_event(id_: int, value: int) -> bytes:
|
|
||||||
return bytes([id_]) + struct.pack("<H", value)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_dword_event(id_: int, value: int) -> bytes:
|
|
||||||
return bytes([id_]) + struct.pack("<I", value)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_text_event(id_: int, text: str) -> bytes:
|
|
||||||
data = encode_text(text)
|
|
||||||
return bytes([id_]) + encode_varint(len(data)) + data
|
|
||||||
|
|
||||||
|
|
||||||
def encode_data_event(id_: int, data: bytes) -> bytes:
|
|
||||||
return bytes([id_]) + encode_varint(len(data)) + data
|
|
||||||
|
|
||||||
|
|
||||||
def encode_note_24(
|
|
||||||
position: int,
|
|
||||||
flags: int,
|
|
||||||
rack_channel: int,
|
|
||||||
length: int,
|
|
||||||
key: int,
|
|
||||||
group: int,
|
|
||||||
fine_pitch: int,
|
|
||||||
release: int,
|
|
||||||
midi_channel: int,
|
|
||||||
pan: int,
|
|
||||||
velocity: int,
|
|
||||||
mod_x: int,
|
|
||||||
mod_y: int,
|
|
||||||
) -> bytes:
|
|
||||||
"""Encode a single note in FL Studio's 24-byte format.
|
|
||||||
|
|
||||||
Format (24 bytes, all absolute values):
|
|
||||||
position: uint32 (4) - absolute position in PPQ ticks
|
|
||||||
flags: uint16 (2) - note flags (0x4000 = standard note)
|
|
||||||
rack_channel: uint16 (2) - channel rack index
|
|
||||||
length: uint32 (4) - duration in PPQ ticks
|
|
||||||
key: uint16 (2) - MIDI note number (0-127)
|
|
||||||
group: uint16 (2) - note group
|
|
||||||
fine_pitch: uint8 (1) - fine pitch (0x78 = 120 = no detune)
|
|
||||||
_u1: uint8 (1) - unknown (0x40)
|
|
||||||
release: uint8 (1) - release value
|
|
||||||
midi_channel: uint8 (1) - MIDI channel
|
|
||||||
pan: int8 (1) - stereo pan (64 = center)
|
|
||||||
velocity: uint8 (1) - note velocity
|
|
||||||
mod_x: uint8 (1) - modulation X (128 = center)
|
|
||||||
mod_y: uint8 (1) - modulation Y (128 = center)
|
|
||||||
"""
|
|
||||||
return struct.pack(
|
|
||||||
"<IHHIHHBBBBBBBB",
|
|
||||||
position,
|
|
||||||
flags,
|
|
||||||
rack_channel,
|
|
||||||
length,
|
|
||||||
key,
|
|
||||||
group,
|
|
||||||
fine_pitch,
|
|
||||||
0x40, # unknown byte, always 0x40 in observed data
|
|
||||||
release,
|
|
||||||
midi_channel,
|
|
||||||
pan,
|
|
||||||
velocity,
|
|
||||||
mod_x,
|
|
||||||
mod_y,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_notes_block(
|
|
||||||
channel_index: int,
|
|
||||||
notes: list[dict],
|
|
||||||
ppq: int = 96,
|
|
||||||
) -> bytes:
|
|
||||||
"""Encode all notes for a pattern as raw note data (no header).
|
|
||||||
|
|
||||||
FL Studio stores notes as a flat array of 24-byte structs.
|
|
||||||
No header or count prefix needed - the event size determines count.
|
|
||||||
"""
|
|
||||||
note_data = bytearray()
|
|
||||||
|
|
||||||
for note in notes:
|
|
||||||
pos = int(note.get("position", 0) * ppq)
|
|
||||||
length = int(note.get("length", 1) * ppq)
|
|
||||||
key = note.get("key", 60)
|
|
||||||
velocity = note.get("velocity", 100)
|
|
||||||
rack_channel = note.get("rack_channel", channel_index)
|
|
||||||
|
|
||||||
note_bytes = encode_note_24(
|
|
||||||
position=pos,
|
|
||||||
flags=0x4000,
|
|
||||||
rack_channel=rack_channel,
|
|
||||||
length=max(length, 1),
|
|
||||||
key=key & 0x7F,
|
|
||||||
group=0,
|
|
||||||
fine_pitch=120,
|
|
||||||
release=64,
|
|
||||||
midi_channel=0,
|
|
||||||
pan=64,
|
|
||||||
velocity=velocity & 0x7F,
|
|
||||||
mod_x=128,
|
|
||||||
mod_y=128,
|
|
||||||
)
|
|
||||||
note_data.extend(note_bytes)
|
|
||||||
|
|
||||||
return bytes(note_data)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Note:
|
|
||||||
position: float
|
|
||||||
length: float
|
|
||||||
key: int
|
|
||||||
velocity: int = 100
|
|
||||||
fine_pitch: int = 0
|
|
||||||
pan: int = 0
|
|
||||||
midi_channel: int = 0
|
|
||||||
slide: bool = False
|
|
||||||
release: int = 0
|
|
||||||
mod_x: int = 0
|
|
||||||
mod_y: int = 0
|
|
||||||
group: int = 0
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"position": self.position,
|
|
||||||
"length": self.length,
|
|
||||||
"key": self.key,
|
|
||||||
"velocity": self.velocity,
|
|
||||||
"fine_pitch": self.fine_pitch,
|
|
||||||
"pan": self.pan,
|
|
||||||
"midi_channel": self.midi_channel,
|
|
||||||
"slide": self.slide,
|
|
||||||
"release": self.release,
|
|
||||||
"mod_x": self.mod_x,
|
|
||||||
"mod_y": self.mod_y,
|
|
||||||
"group": self.group,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Pattern:
|
|
||||||
name: str = ""
|
|
||||||
index: int = 0
|
|
||||||
notes: dict[int, list[Note]] = field(default_factory=dict)
|
|
||||||
color: int = 0
|
|
||||||
length: int = 0
|
|
||||||
|
|
||||||
def add_note(self, channel_index: int, note: Note):
|
|
||||||
if channel_index not in self.notes:
|
|
||||||
self.notes[channel_index] = []
|
|
||||||
self.notes[channel_index].append(note)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Plugin:
|
|
||||||
internal_name: str = ""
|
|
||||||
display_name: str = ""
|
|
||||||
plugin_data: Optional[bytes] = None
|
|
||||||
color: int = 0
|
|
||||||
icon: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Channel:
|
|
||||||
name: str = ""
|
|
||||||
index: int = 0
|
|
||||||
enabled: bool = True
|
|
||||||
volume: int = 256
|
|
||||||
pan: int = 0
|
|
||||||
plugin: Optional[Plugin] = None
|
|
||||||
mixer_track: int = 0
|
|
||||||
color: int = 0
|
|
||||||
root_note: int = 60
|
|
||||||
channel_type: int = 0
|
|
||||||
|
|
||||||
FL_TYPE_GENERATOR = 2
|
|
||||||
FL_TYPE_SAMPLER = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MixerTrack:
|
|
||||||
name: str = ""
|
|
||||||
index: int = 0
|
|
||||||
volume: float = 1.0
|
|
||||||
pan: float = 0.0
|
|
||||||
muted: bool = False
|
|
||||||
effects: list[Plugin] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FLPProject:
|
|
||||||
tempo: float = 140.0
|
|
||||||
title: str = ""
|
|
||||||
genre: str = ""
|
|
||||||
artists: str = ""
|
|
||||||
comments: str = ""
|
|
||||||
fl_version: str = "24.7.1.73"
|
|
||||||
ppq: int = 96
|
|
||||||
channels: list[Channel] = field(default_factory=list)
|
|
||||||
patterns: list[Pattern] = field(default_factory=list)
|
|
||||||
mixer_tracks: list[MixerTrack] = field(default_factory=list)
|
|
||||||
|
|
||||||
def add_channel(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
plugin_internal_name: str = "",
|
|
||||||
plugin_display_name: str = "",
|
|
||||||
plugin_data: Optional[bytes] = None,
|
|
||||||
mixer_track: int = -1,
|
|
||||||
channel_type: int = 2,
|
|
||||||
volume: int = 256,
|
|
||||||
) -> Channel:
|
|
||||||
idx = len(self.channels)
|
|
||||||
plugin = None
|
|
||||||
if plugin_internal_name:
|
|
||||||
plugin = Plugin(
|
|
||||||
internal_name=plugin_internal_name,
|
|
||||||
display_name=plugin_display_name or plugin_internal_name,
|
|
||||||
plugin_data=plugin_data,
|
|
||||||
)
|
|
||||||
ch = Channel(
|
|
||||||
name=name,
|
|
||||||
index=idx,
|
|
||||||
plugin=plugin,
|
|
||||||
mixer_track=mixer_track if mixer_track >= 0 else idx,
|
|
||||||
channel_type=channel_type,
|
|
||||||
volume=volume,
|
|
||||||
)
|
|
||||||
self.channels.append(ch)
|
|
||||||
return ch
|
|
||||||
|
|
||||||
def add_pattern(self, name: str = "") -> Pattern:
|
|
||||||
idx = len(self.patterns) + 1
|
|
||||||
pat = Pattern(name=name, index=idx)
|
|
||||||
self.patterns.append(pat)
|
|
||||||
return pat
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"""Song definition schema for FL Studio FLP generation.
|
|
||||||
|
|
||||||
Provides the JSON contract that decouples song composition from FLP rendering.
|
|
||||||
A SongDefinition is the single source of truth for one ``.flp`` file.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
song = SongDefinition.load_file("knowledge/songs/reggaeton_template.json")
|
|
||||||
errors = song.validate()
|
|
||||||
json_str = song.to_json()
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Key validation pattern: A-G, optional flat/sharp, optional minor 'm'
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_KEY_RE = re.compile(r"^[A-G][b#]?m?$")
|
|
||||||
|
|
||||||
# Allowed top-level keys in the JSON document
|
|
||||||
_TOP_LEVEL_KEYS = frozenset({
|
|
||||||
"meta", "samples", "patterns", "tracks", "items",
|
|
||||||
"melodic_tracks", "progression_name", "section_template",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Allowed keys in nested objects
|
|
||||||
_META_KEYS = frozenset({
|
|
||||||
"bpm", "key", "title", "ppq", "time_sig_num", "time_sig_den",
|
|
||||||
})
|
|
||||||
_PATTERN_KEYS = frozenset({
|
|
||||||
"id", "name", "instrument", "channel", "bars", "generator",
|
|
||||||
"velocity_mult", "density",
|
|
||||||
})
|
|
||||||
_TRACK_KEYS = frozenset({"index", "name"})
|
|
||||||
_ITEM_KEYS = frozenset({"pattern", "bar", "bars", "track", "muted"})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dataclasses
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongMeta:
|
|
||||||
"""Song metadata — tempo, key, time signature."""
|
|
||||||
|
|
||||||
bpm: float # 20–999
|
|
||||||
key: str # e.g. "Am", "Dm", "Gm"
|
|
||||||
title: str # song title
|
|
||||||
ppq: int = 96 # ticks per quarter note
|
|
||||||
time_sig_num: int = 4
|
|
||||||
time_sig_den: int = 4
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PatternNote:
|
|
||||||
"""A single note within a pattern (used when embedding notes directly)."""
|
|
||||||
|
|
||||||
pos: float # beat position (0.0 = beat 1 of bar)
|
|
||||||
len: float # duration in beats
|
|
||||||
key: int # MIDI note (60 = C4)
|
|
||||||
vel: int # velocity 0–127
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PatternDef:
|
|
||||||
"""Pattern definition — recipe for generating note data.
|
|
||||||
|
|
||||||
The ``generator`` field names a function in ``composer/rhythm.py``
|
|
||||||
that produces the actual MIDI notes for this pattern.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: int # pattern number (1-based)
|
|
||||||
name: str # human label
|
|
||||||
instrument: str # "kick", "snare", "hihat", etc.
|
|
||||||
channel: int # channel rack index (10–16)
|
|
||||||
bars: int # pattern length in bars
|
|
||||||
generator: str # rhythm.py function name
|
|
||||||
velocity_mult: float = 1.0 # scales all velocities
|
|
||||||
density: float = 1.0 # 0.5=sparse, 1.0=full
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ArrangementTrack:
|
|
||||||
"""A track row in the FL Studio playlist / arrangement."""
|
|
||||||
|
|
||||||
index: int # 1-based track index in arrangement
|
|
||||||
name: str # display name
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ArrangementItemDef:
|
|
||||||
"""Placement of a pattern on the arrangement timeline."""
|
|
||||||
|
|
||||||
pattern: int # pattern id
|
|
||||||
bar: float # start bar (0-based)
|
|
||||||
bars: float # duration in bars
|
|
||||||
track: int # track index (1-based, must exist in tracks[])
|
|
||||||
muted: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MelodicNote:
|
|
||||||
"""A single note in a melodic track. Unified format: {pos, len, key, vel}."""
|
|
||||||
|
|
||||||
pos: float # beat position (0.0 = beat 1 of bar)
|
|
||||||
len: float # duration in beats
|
|
||||||
key: int # MIDI note (60 = C4)
|
|
||||||
vel: int # velocity 0–127
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MelodicTrack:
|
|
||||||
"""A melodic track referencing an audio sample with MIDI note triggers.
|
|
||||||
|
|
||||||
The sample is loaded into a sampler channel and notes trigger playback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
role: str # "bass", "lead", "pad", "pluck", etc.
|
|
||||||
sample_path: str # absolute path to .wav file
|
|
||||||
notes: list[MelodicNote] # note events
|
|
||||||
channel_index: int # FL Studio channel (17+ for melodic)
|
|
||||||
volume: float = 0.85 # 0.0–1.0
|
|
||||||
pan: float = 0.0 # -1.0 to 1.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongDefinition:
|
|
||||||
"""Complete song definition — the single source of truth for one .flp.
|
|
||||||
|
|
||||||
Serialization round-trips through ``to_json()`` / ``from_json()``.
|
|
||||||
Use ``validate()`` to check constraints before rendering.
|
|
||||||
"""
|
|
||||||
|
|
||||||
meta: SongMeta
|
|
||||||
samples: dict[str, str] # {"kick": "kick.wav", ...}
|
|
||||||
patterns: list[PatternDef]
|
|
||||||
tracks: list[ArrangementTrack]
|
|
||||||
items: list[ArrangementItemDef]
|
|
||||||
melodic_tracks: list[MelodicTrack] = field(default_factory=list)
|
|
||||||
|
|
||||||
# Optional metadata for variation engine
|
|
||||||
progression_name: str = ""
|
|
||||||
section_template: str = "standard"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Validation
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def validate(self) -> list[str]:
|
|
||||||
"""Return list of validation errors (empty list = valid).
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. meta.bpm in 20–999
|
|
||||||
2. meta.key matches ``^[A-G][b#]?m?$``
|
|
||||||
3. meta.ppq == 96
|
|
||||||
4. All pattern ``id`` values are unique
|
|
||||||
5. All ``item.pattern`` reference an existing pattern id
|
|
||||||
6. All ``item.track`` reference an existing track index
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
# 1. BPM range
|
|
||||||
if not (20 <= self.meta.bpm <= 999):
|
|
||||||
errors.append(
|
|
||||||
f"meta.bpm must be 20–999, got {self.meta.bpm}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Key format
|
|
||||||
if not _KEY_RE.match(self.meta.key):
|
|
||||||
errors.append(
|
|
||||||
f"meta.key must match ^[A-G][b#]?m?$, got '{self.meta.key}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. PPQ
|
|
||||||
if self.meta.ppq != 96:
|
|
||||||
errors.append(
|
|
||||||
f"meta.ppq must be 96, got {self.meta.ppq}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Unique pattern ids
|
|
||||||
pattern_ids = [p.id for p in self.patterns]
|
|
||||||
seen: set[int] = set()
|
|
||||||
for pid in pattern_ids:
|
|
||||||
if pid in seen:
|
|
||||||
errors.append(f"Duplicate pattern id: {pid}")
|
|
||||||
seen.add(pid)
|
|
||||||
|
|
||||||
valid_pattern_ids = set(pattern_ids)
|
|
||||||
|
|
||||||
# 5. All items reference valid pattern id
|
|
||||||
for i, item in enumerate(self.items):
|
|
||||||
if item.pattern not in valid_pattern_ids:
|
|
||||||
errors.append(
|
|
||||||
f"items[{i}].pattern={item.pattern} does not reference "
|
|
||||||
f"an existing pattern id"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. All items reference valid track index
|
|
||||||
valid_track_indices = {t.index for t in self.tracks}
|
|
||||||
for i, item in enumerate(self.items):
|
|
||||||
if item.track not in valid_track_indices:
|
|
||||||
errors.append(
|
|
||||||
f"items[{i}].track={item.track} does not reference "
|
|
||||||
f"an existing track index"
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Serialization
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def to_json(self, indent: int = 2) -> str:
|
|
||||||
"""Serialize to a JSON string."""
|
|
||||||
return json.dumps(asdict(self), indent=indent, ensure_ascii=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_json(cls, data: str | dict) -> SongDefinition:
|
|
||||||
"""Deserialize from a JSON string or dict.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: On unknown keys, missing fields, or validation errors.
|
|
||||||
"""
|
|
||||||
if isinstance(data, str):
|
|
||||||
raw = json.loads(data)
|
|
||||||
else:
|
|
||||||
raw = data
|
|
||||||
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"Expected dict, got {type(raw).__name__}")
|
|
||||||
|
|
||||||
# Reject unknown top-level keys
|
|
||||||
unknown = set(raw.keys()) - _TOP_LEVEL_KEYS
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"Unknown top-level keys: {sorted(unknown)}")
|
|
||||||
|
|
||||||
# --- meta ---
|
|
||||||
meta_raw = raw.get("meta")
|
|
||||||
if not isinstance(meta_raw, dict):
|
|
||||||
raise ValueError("Missing or invalid 'meta' object")
|
|
||||||
|
|
||||||
unknown_meta = set(meta_raw.keys()) - _META_KEYS
|
|
||||||
if unknown_meta:
|
|
||||||
raise ValueError(f"Unknown meta keys: {sorted(unknown_meta)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
meta = SongMeta(
|
|
||||||
bpm=float(meta_raw["bpm"]),
|
|
||||||
key=str(meta_raw["key"]),
|
|
||||||
title=str(meta_raw.get("title", "")),
|
|
||||||
ppq=int(meta_raw.get("ppq", 96)),
|
|
||||||
time_sig_num=int(meta_raw.get("time_sig_num", 4)),
|
|
||||||
time_sig_den=int(meta_raw.get("time_sig_den", 4)),
|
|
||||||
)
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(f"Missing required meta field: {exc}") from exc
|
|
||||||
|
|
||||||
# --- samples ---
|
|
||||||
samples = raw.get("samples")
|
|
||||||
if not isinstance(samples, dict):
|
|
||||||
raise ValueError("Missing or invalid 'samples' dict")
|
|
||||||
|
|
||||||
# --- patterns ---
|
|
||||||
patterns_raw = raw.get("patterns")
|
|
||||||
if not isinstance(patterns_raw, list):
|
|
||||||
raise ValueError("Missing or invalid 'patterns' list")
|
|
||||||
|
|
||||||
patterns: list[PatternDef] = []
|
|
||||||
for idx, p in enumerate(patterns_raw):
|
|
||||||
if not isinstance(p, dict):
|
|
||||||
raise ValueError(f"patterns[{idx}] must be a dict")
|
|
||||||
unknown_p = set(p.keys()) - _PATTERN_KEYS
|
|
||||||
if unknown_p:
|
|
||||||
raise ValueError(
|
|
||||||
f"patterns[{idx}] unknown keys: {sorted(unknown_p)}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
patterns.append(PatternDef(
|
|
||||||
id=int(p["id"]),
|
|
||||||
name=str(p["name"]),
|
|
||||||
instrument=str(p["instrument"]),
|
|
||||||
channel=int(p["channel"]),
|
|
||||||
bars=int(p["bars"]),
|
|
||||||
generator=str(p["generator"]),
|
|
||||||
velocity_mult=float(p.get("velocity_mult", 1.0)),
|
|
||||||
density=float(p.get("density", 1.0)),
|
|
||||||
))
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
f"patterns[{idx}] missing required field: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
# --- tracks ---
|
|
||||||
tracks_raw = raw.get("tracks")
|
|
||||||
if not isinstance(tracks_raw, list):
|
|
||||||
raise ValueError("Missing or invalid 'tracks' list")
|
|
||||||
|
|
||||||
tracks: list[ArrangementTrack] = []
|
|
||||||
for idx, t in enumerate(tracks_raw):
|
|
||||||
if not isinstance(t, dict):
|
|
||||||
raise ValueError(f"tracks[{idx}] must be a dict")
|
|
||||||
unknown_t = set(t.keys()) - _TRACK_KEYS
|
|
||||||
if unknown_t:
|
|
||||||
raise ValueError(
|
|
||||||
f"tracks[{idx}] unknown keys: {sorted(unknown_t)}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
tracks.append(ArrangementTrack(
|
|
||||||
index=int(t["index"]),
|
|
||||||
name=str(t["name"]),
|
|
||||||
))
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
f"tracks[{idx}] missing required field: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
# --- items ---
|
|
||||||
items_raw = raw.get("items")
|
|
||||||
if not isinstance(items_raw, list):
|
|
||||||
raise ValueError("Missing or invalid 'items' list")
|
|
||||||
|
|
||||||
items: list[ArrangementItemDef] = []
|
|
||||||
for idx, it in enumerate(items_raw):
|
|
||||||
if not isinstance(it, dict):
|
|
||||||
raise ValueError(f"items[{idx}] must be a dict")
|
|
||||||
unknown_it = set(it.keys()) - _ITEM_KEYS
|
|
||||||
if unknown_it:
|
|
||||||
raise ValueError(
|
|
||||||
f"items[{idx}] unknown keys: {sorted(unknown_it)}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
items.append(ArrangementItemDef(
|
|
||||||
pattern=int(it["pattern"]),
|
|
||||||
bar=float(it["bar"]),
|
|
||||||
bars=float(it["bars"]),
|
|
||||||
track=int(it["track"]),
|
|
||||||
muted=bool(it.get("muted", False)),
|
|
||||||
))
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
f"items[{idx}] missing required field: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
song = cls(
|
|
||||||
meta=meta,
|
|
||||||
samples=samples,
|
|
||||||
patterns=patterns,
|
|
||||||
tracks=tracks,
|
|
||||||
items=items,
|
|
||||||
progression_name=str(raw.get("progression_name", "")),
|
|
||||||
section_template=str(raw.get("section_template", "standard")),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate and raise on errors
|
|
||||||
errors = song.validate()
|
|
||||||
if errors:
|
|
||||||
raise ValueError(
|
|
||||||
"Song validation failed:\n - " + "\n - ".join(errors)
|
|
||||||
)
|
|
||||||
|
|
||||||
return song
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_file(cls, path: str | Path) -> SongDefinition:
|
|
||||||
"""Load and validate from a ``.json`` file.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If the file does not exist.
|
|
||||||
ValueError: If validation fails.
|
|
||||||
"""
|
|
||||||
p = Path(path)
|
|
||||||
if not p.exists():
|
|
||||||
raise FileNotFoundError(f"Song file not found: {p}")
|
|
||||||
return cls.from_json(p.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Convenience
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def load_song_json(path: str | Path) -> SongDefinition:
|
|
||||||
"""Load + validate a song definition from a JSON file.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If validation fails.
|
|
||||||
FileNotFoundError: If file does not exist.
|
|
||||||
"""
|
|
||||||
return SongDefinition.load_file(path)
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
"""Channel skeleton loader — extracts sampler channels from reference FLP and patches sample paths."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Default channel→sample mapping (index: sample_key)
|
|
||||||
# Only Ch10-19 are sampler channels in the reference FLP
|
|
||||||
DEFAULT_CHANNEL_MAP = {
|
|
||||||
10: "channel10",
|
|
||||||
11: "channel11",
|
|
||||||
12: "channel12",
|
|
||||||
13: "channel13",
|
|
||||||
14: "channel14",
|
|
||||||
15: "channel15",
|
|
||||||
16: "channel16",
|
|
||||||
17: "channel17",
|
|
||||||
18: "channel18",
|
|
||||||
19: "channel19",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Channels to replace with empty sampler (non-drum channels from original)
|
|
||||||
EMPTY_SAMPLER_CHANNELS = {3, 4, 8, 17, 18, 19}
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelSkeletonLoader:
|
|
||||||
"""Loads sampler channel configuration from a reference FLP binary.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
loader = ChannelSkeletonLoader(ref_flp_path, ch11_template_path, samples_dir)
|
|
||||||
channel_bytes = loader.load(sample_map={"kick": "kick.wav", ...})
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ref_flp_path: str, ch11_template_path: str, samples_dir: str):
|
|
||||||
self.ref_flp_path = ref_flp_path
|
|
||||||
self.ch11_template_path = ch11_template_path
|
|
||||||
self.samples_dir = samples_dir
|
|
||||||
self._cache: bytes | None = None
|
|
||||||
self._ch11_template: bytes | None = None
|
|
||||||
|
|
||||||
def load(
|
|
||||||
self,
|
|
||||||
sample_map: dict[str, str] | None = None,
|
|
||||||
melodic_map: dict[int, tuple[str, str]] | None = None,
|
|
||||||
) -> bytes:
|
|
||||||
"""Return assembled channel bytes with sample paths patched.
|
|
||||||
|
|
||||||
sample_map: {"kick": "kick.wav", "snare": "snare.wav", ...}
|
|
||||||
Keys must match DEFAULT_CHANNEL_MAP values.
|
|
||||||
If None, uses DEFAULT_CHANNEL_MAP with filenames as "<key>.wav"
|
|
||||||
melodic_map: {ch_idx: (samples_dir, wav_name), ...}
|
|
||||||
Maps melodic channel indices to their sample file.
|
|
||||||
These channels get sampler clones with real samples instead of empty.
|
|
||||||
Returns raw bytes for all channels (stripped of post-channel data).
|
|
||||||
Caches result — calling load() multiple times returns same bytes.
|
|
||||||
"""
|
|
||||||
if self._cache is not None:
|
|
||||||
return self._cache
|
|
||||||
|
|
||||||
# Resolve sample_map: map channel_index → wav filename
|
|
||||||
if sample_map is None:
|
|
||||||
ch_to_wav = {ch: f"{key}.wav" for ch, key in DEFAULT_CHANNEL_MAP.items()}
|
|
||||||
else:
|
|
||||||
ch_to_wav = {ch: sample_map[key] for ch, key in DEFAULT_CHANNEL_MAP.items() if key in sample_map}
|
|
||||||
|
|
||||||
melodic_channels = set(melodic_map.keys()) if melodic_map else set()
|
|
||||||
|
|
||||||
extracted = self._extract_channels()
|
|
||||||
order = extracted["order"]
|
|
||||||
segments: dict[int, bytearray] = extracted["segments"]
|
|
||||||
|
|
||||||
# Replace channels not in drum/melodic maps with empty sampler clones
|
|
||||||
channels_with_samples = set(ch_to_wav.keys()) | melodic_channels
|
|
||||||
for ch_idx in list(segments.keys()):
|
|
||||||
if ch_idx not in channels_with_samples:
|
|
||||||
segments[ch_idx] = bytearray(self._make_empty_sampler(ch_idx))
|
|
||||||
|
|
||||||
# For melodic channels: clone ch11 template and patch with real sample path
|
|
||||||
if melodic_map:
|
|
||||||
for ch_idx, (sample_dir, wav_name) in melodic_map.items():
|
|
||||||
if ch_idx in segments:
|
|
||||||
segments[ch_idx] = bytearray(
|
|
||||||
self._make_sampler_with_sample(ch_idx, sample_dir, wav_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Patch sample paths for drum channels (skip melodic — already patched)
|
|
||||||
for ch_idx, wav_name in ch_to_wav.items():
|
|
||||||
if ch_idx in segments and ch_idx not in melodic_channels:
|
|
||||||
segments[ch_idx] = bytearray(self._patch_sample_path(bytes(segments[ch_idx]), wav_name))
|
|
||||||
|
|
||||||
# Assemble in original order
|
|
||||||
buf = bytearray()
|
|
||||||
for ch_idx in order:
|
|
||||||
buf += segments[ch_idx]
|
|
||||||
|
|
||||||
self._cache = bytes(buf)
|
|
||||||
return self._cache
|
|
||||||
|
|
||||||
# ── Event parsing ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _read_ev(self, data: bytes, pos: int) -> tuple:
|
|
||||||
"""Read one FLP event. Returns (next_pos, start, event_id, value, value_type)."""
|
|
||||||
start = pos
|
|
||||||
ib = data[pos]
|
|
||||||
pos += 1
|
|
||||||
|
|
||||||
if ib < 64:
|
|
||||||
# Byte event: 1 byte ID + 1 byte value
|
|
||||||
return pos + 1, start, ib, data[start + 1], "byte"
|
|
||||||
elif ib < 128:
|
|
||||||
# Word event: 1 byte ID + 2 byte value
|
|
||||||
return pos + 2, start, ib, struct.unpack("<H", data[pos : pos + 2])[0], "word"
|
|
||||||
elif ib < 192:
|
|
||||||
# Dword event: 1 byte ID + 4 byte value
|
|
||||||
return pos + 4, start, ib, struct.unpack("<I", data[pos : pos + 4])[0], "dword"
|
|
||||||
else:
|
|
||||||
# Data/TEXT event: 1 byte ID + varint size + payload
|
|
||||||
sz = 0
|
|
||||||
sh = 0
|
|
||||||
while True:
|
|
||||||
b = data[pos]
|
|
||||||
pos += 1
|
|
||||||
sz |= (b & 0x7F) << sh
|
|
||||||
sh += 7
|
|
||||||
if not (b & 0x80):
|
|
||||||
break
|
|
||||||
return pos + sz, start, ib, data[pos : pos + sz], "data"
|
|
||||||
|
|
||||||
def _encode_varint(self, n: int) -> bytes:
|
|
||||||
"""Encode an integer as a varint (LEB128)."""
|
|
||||||
r = bytearray()
|
|
||||||
while True:
|
|
||||||
b = n & 0x7F
|
|
||||||
n >>= 7
|
|
||||||
if n:
|
|
||||||
b |= 0x80
|
|
||||||
r.append(b)
|
|
||||||
if not n:
|
|
||||||
break
|
|
||||||
return bytes(r)
|
|
||||||
|
|
||||||
# ── Channel extraction ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _extract_channels(self) -> dict:
|
|
||||||
"""Parse reference FLP, extract channel segments, find post-channel boundary.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'order': [ch_idx, ...], # channels in original order
|
|
||||||
'segments': {idx: bytes}, # raw bytes per channel
|
|
||||||
'last_ch': idx, # index of last channel
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
with open(self.ref_flp_path, "rb") as f:
|
|
||||||
data = f.read()
|
|
||||||
|
|
||||||
# Skip FLhd header (6 bytes) + FLdt chunk header (8 bytes) = 14 bytes,
|
|
||||||
# then the FLhd body. v15 starts scanning at offset 22.
|
|
||||||
pos = 22
|
|
||||||
first_ch = None
|
|
||||||
current_ch = -1
|
|
||||||
ch_ranges: dict[int, list[int]] = {}
|
|
||||||
channels_order: list[int] = []
|
|
||||||
|
|
||||||
# Import here to avoid circular — events is a leaf module
|
|
||||||
from src.flp_builder.events import EventID
|
|
||||||
|
|
||||||
while pos < len(data):
|
|
||||||
np, st, ib, val, vt = self._read_ev(data, pos)
|
|
||||||
if ib == EventID.ChNew:
|
|
||||||
if first_ch is None:
|
|
||||||
first_ch = st
|
|
||||||
if current_ch >= 0:
|
|
||||||
ch_ranges[current_ch] = (ch_ranges[current_ch][0], st)
|
|
||||||
current_ch = val
|
|
||||||
ch_ranges[current_ch] = (st, st)
|
|
||||||
channels_order.append(current_ch)
|
|
||||||
pos = np
|
|
||||||
|
|
||||||
if current_ch >= 0:
|
|
||||||
ch_ranges[current_ch] = (ch_ranges[current_ch][0], len(data))
|
|
||||||
|
|
||||||
if not channels_order:
|
|
||||||
raise ValueError("No channels found in reference FLP")
|
|
||||||
|
|
||||||
# Find post-channel boundary in last channel segment
|
|
||||||
# Scan for ID 99 (ArrNew) — everything from there onward is post-channel
|
|
||||||
last_ch = channels_order[-1]
|
|
||||||
last_seg_start = ch_ranges[last_ch][0]
|
|
||||||
last_seg_data = data[last_seg_start:]
|
|
||||||
p = 0
|
|
||||||
post_ch_offset = len(last_seg_data)
|
|
||||||
while p < len(last_seg_data):
|
|
||||||
np, st, ib, val, vt = self._read_ev(last_seg_data, p)
|
|
||||||
if ib == 99: # ArrNew
|
|
||||||
post_ch_offset = st
|
|
||||||
break
|
|
||||||
p = np
|
|
||||||
|
|
||||||
# Build channel segments, stripping post-channel data from last one
|
|
||||||
segments: dict[int, bytearray] = {}
|
|
||||||
for ch_idx in channels_order:
|
|
||||||
s, e = ch_ranges[ch_idx]
|
|
||||||
if ch_idx == last_ch:
|
|
||||||
segments[ch_idx] = bytearray(data[s : s + post_ch_offset])
|
|
||||||
else:
|
|
||||||
segments[ch_idx] = bytearray(data[s:e])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"order": channels_order,
|
|
||||||
"segments": segments,
|
|
||||||
"last_ch": last_ch,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Sampler with real sample ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Events to strip when cloning: old sample path, old sample name, cached data
|
|
||||||
STRIP_EVENTS = {0xC4, 0xCB, 0xDA, 0xD7, 0xE4, 0xE5, 0xDD, 0xD1}
|
|
||||||
|
|
||||||
def _make_sampler_with_sample(self, ch_idx: int, samples_dir: str, wav_name: str) -> bytes:
|
|
||||||
"""Clone the FL Studio-created sampler template and patch with real sample.
|
|
||||||
|
|
||||||
Uses output/flstudio_sampler_template.bin which was extracted from a
|
|
||||||
channel that FL Studio itself created (guaranteed correct format).
|
|
||||||
"""
|
|
||||||
template_path = os.path.join(
|
|
||||||
os.path.dirname(self.ref_flp_path), "..", "output", "flstudio_sampler_template.bin"
|
|
||||||
)
|
|
||||||
template_path = os.path.normpath(template_path)
|
|
||||||
if not os.path.isfile(template_path):
|
|
||||||
# Fallback: extract from debug_sampler.flp
|
|
||||||
raise FileNotFoundError(f"Sampler template not found: {template_path}")
|
|
||||||
|
|
||||||
with open(template_path, "rb") as f:
|
|
||||||
source = f.read()
|
|
||||||
|
|
||||||
# Rebuild: keep non-cached events, patch ChNew index
|
|
||||||
seg = bytearray()
|
|
||||||
pos = 0
|
|
||||||
while pos < len(source):
|
|
||||||
np, st, ib, val, vt = self._read_ev(source, pos)
|
|
||||||
if ib in self.STRIP_EVENTS:
|
|
||||||
pass # Remove stale cached data
|
|
||||||
elif ib == 0x40 and vt == "word":
|
|
||||||
seg += struct.pack("<BH", 0x40, ch_idx)
|
|
||||||
else:
|
|
||||||
seg += source[st:np]
|
|
||||||
pos = np
|
|
||||||
|
|
||||||
# Add sample name (0xCB)
|
|
||||||
sample_name = os.path.splitext(wav_name)[0]
|
|
||||||
encoded_name = sample_name.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
seg += bytes([0xCB]) + self._encode_varint(len(encoded_name)) + encoded_name
|
|
||||||
|
|
||||||
# Add sample path (0xC4) — absolute path, no %USERPROFILE%
|
|
||||||
full_path = os.path.join(samples_dir, wav_name)
|
|
||||||
encoded_path = full_path.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
seg += bytes([0xC4]) + self._encode_varint(len(encoded_path)) + encoded_path
|
|
||||||
|
|
||||||
return bytes(seg)
|
|
||||||
|
|
||||||
def _extract_channels_raw(self) -> dict[int, bytes]:
|
|
||||||
"""Extract raw channel segments from reference FLP without caching.
|
|
||||||
Returns {ch_idx: bytes}."""
|
|
||||||
with open(self.ref_flp_path, "rb") as f:
|
|
||||||
data = f.read()
|
|
||||||
|
|
||||||
from src.flp_builder.events import EventID
|
|
||||||
|
|
||||||
pos = 22
|
|
||||||
current_ch = -1
|
|
||||||
ch_ranges: dict[int, tuple[int, int]] = {}
|
|
||||||
channels_order: list[int] = []
|
|
||||||
|
|
||||||
while pos < len(data):
|
|
||||||
np, st, ib, val, vt = self._read_ev(data, pos)
|
|
||||||
if ib == EventID.ChNew:
|
|
||||||
if current_ch >= 0:
|
|
||||||
ch_ranges[current_ch] = (ch_ranges[current_ch][0], st)
|
|
||||||
current_ch = val
|
|
||||||
ch_ranges[current_ch] = (st, st)
|
|
||||||
channels_order.append(current_ch)
|
|
||||||
pos = np
|
|
||||||
|
|
||||||
if current_ch >= 0:
|
|
||||||
ch_ranges[current_ch] = (ch_ranges[current_ch][0], len(data))
|
|
||||||
|
|
||||||
# Strip post-channel data from last channel
|
|
||||||
last_ch = channels_order[-1]
|
|
||||||
last_start = ch_ranges[last_ch][0]
|
|
||||||
last_data = data[last_start:]
|
|
||||||
p = 0
|
|
||||||
post_offset = len(last_data)
|
|
||||||
while p < len(last_data):
|
|
||||||
np, st, ib, val, vt = self._read_ev(last_data, p)
|
|
||||||
if ib == 99:
|
|
||||||
post_offset = st
|
|
||||||
break
|
|
||||||
p = np
|
|
||||||
|
|
||||||
segments: dict[int, bytes] = {}
|
|
||||||
for ch_idx in channels_order:
|
|
||||||
s, e = ch_ranges[ch_idx]
|
|
||||||
if ch_idx == last_ch:
|
|
||||||
segments[ch_idx] = data[s:s + post_offset]
|
|
||||||
else:
|
|
||||||
segments[ch_idx] = data[s:e]
|
|
||||||
|
|
||||||
return segments
|
|
||||||
|
|
||||||
def _patch_chnew_index(self, seg: bytearray, new_idx: int):
|
|
||||||
"""Find and patch the ChNew word event to a new channel index."""
|
|
||||||
pos = 0
|
|
||||||
while pos < len(seg):
|
|
||||||
np, st, ib, val, vt = self._read_ev(bytes(seg), pos)
|
|
||||||
if ib == 64 and vt == "word": # ChNew
|
|
||||||
struct.pack_into("<H", seg, st + 1, new_idx)
|
|
||||||
return
|
|
||||||
pos = np
|
|
||||||
|
|
||||||
# ── Empty sampler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _make_empty_sampler(self, ch_idx: int) -> bytes:
|
|
||||||
"""Create a minimal empty sampler channel with no sample loaded."""
|
|
||||||
extracted = self._extract_channels_raw()
|
|
||||||
source_idx = 10
|
|
||||||
if source_idx not in extracted:
|
|
||||||
for alt in [11, 12, 13, 14, 15, 16, 17, 18, 19]:
|
|
||||||
if alt in extracted:
|
|
||||||
source_idx = alt
|
|
||||||
break
|
|
||||||
|
|
||||||
seg = bytearray()
|
|
||||||
source = extracted[source_idx]
|
|
||||||
pos = 0
|
|
||||||
while pos < len(source):
|
|
||||||
np, st, ib, val, vt = self._read_ev(source, pos)
|
|
||||||
if ib in self.STRIP_EVENTS or ib == 0xC4:
|
|
||||||
pass # Remove cached data AND old sample path
|
|
||||||
elif ib == 0x40 and vt == "word":
|
|
||||||
seg += struct.pack("<BH", 0x40, ch_idx)
|
|
||||||
else:
|
|
||||||
seg += source[st:np]
|
|
||||||
pos = np
|
|
||||||
|
|
||||||
# Add empty sample path
|
|
||||||
seg += bytes([0xC4, 0x02, 0x00, 0x00])
|
|
||||||
return bytes(seg)
|
|
||||||
|
|
||||||
# ── Sample path patching ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _patch_sample_path(self, seg: bytes, wav_name: str) -> bytes:
|
|
||||||
"""Replace 0xC4 (ChSamplePath) event with encoded wav_path.
|
|
||||||
|
|
||||||
Uses %USERPROFILE% substitution for portability.
|
|
||||||
Paths are encoded as UTF-16-LE + null terminator (\\x00\\x00).
|
|
||||||
"""
|
|
||||||
seg = bytearray(seg)
|
|
||||||
|
|
||||||
# Build full path and substitute USERPROFILE for portability
|
|
||||||
full_path = os.path.join(self.samples_dir, wav_name)
|
|
||||||
userprofile = os.environ.get("USERPROFILE", "")
|
|
||||||
rel_path = full_path.replace(userprofile, "%USERPROFILE%")
|
|
||||||
encoded_path = rel_path.encode("utf-16-le") + b"\x00\x00"
|
|
||||||
|
|
||||||
# Build replacement event: ID byte + varint(size) + encoded path
|
|
||||||
path_ev = bytes([0xC4]) + self._encode_varint(len(encoded_path)) + encoded_path
|
|
||||||
|
|
||||||
# Find all ChSamplePath events
|
|
||||||
local = 0
|
|
||||||
replacements: list[tuple[int, int, bytes]] = []
|
|
||||||
while local < len(seg):
|
|
||||||
nl, es, ib, v, vt = self._read_ev(bytes(seg), local)
|
|
||||||
if ib == 0xC4:
|
|
||||||
replacements.append((es, nl, path_ev))
|
|
||||||
local = nl
|
|
||||||
|
|
||||||
# Apply in reverse to preserve offsets
|
|
||||||
for es, el, nd in reversed(replacements):
|
|
||||||
seg[es:el] = nd
|
|
||||||
|
|
||||||
return bytes(seg)
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import struct
|
|
||||||
from .events import (
|
|
||||||
EventID,
|
|
||||||
encode_byte_event,
|
|
||||||
encode_word_event,
|
|
||||||
encode_dword_event,
|
|
||||||
encode_text_event,
|
|
||||||
encode_data_event,
|
|
||||||
encode_varint,
|
|
||||||
encode_notes_block,
|
|
||||||
)
|
|
||||||
from .project import FLPProject, Pattern, Note
|
|
||||||
|
|
||||||
|
|
||||||
class FLPWriter:
|
|
||||||
def __init__(self, project: FLPProject):
|
|
||||||
self.project = project
|
|
||||||
self._events: list[bytes] = []
|
|
||||||
|
|
||||||
def build(self) -> bytes:
|
|
||||||
self._events = []
|
|
||||||
self._write_project_header()
|
|
||||||
self._write_patterns()
|
|
||||||
self._write_channels()
|
|
||||||
return self._serialize()
|
|
||||||
|
|
||||||
def _add_event(self, data: bytes):
|
|
||||||
self._events.append(data)
|
|
||||||
|
|
||||||
def _write_project_header(self):
|
|
||||||
p = self.project
|
|
||||||
self._add_event(encode_text_event(EventID.FLVersion, p.fl_version))
|
|
||||||
self._add_event(encode_dword_event(EventID.FLBuild, 1773))
|
|
||||||
self._add_event(encode_byte_event(EventID.Licensed, 1))
|
|
||||||
self._add_event(encode_dword_event(EventID.Tempo, int(p.tempo * 1000)))
|
|
||||||
self._add_event(encode_byte_event(EventID.LoopActive, 1))
|
|
||||||
self._add_event(encode_word_event(EventID.Pitch, 0))
|
|
||||||
self._add_event(encode_byte_event(EventID.PanLaw, 0))
|
|
||||||
if p.title:
|
|
||||||
self._add_event(encode_text_event(EventID.Title, p.title))
|
|
||||||
if p.genre:
|
|
||||||
self._add_event(encode_text_event(EventID.Genre, p.genre))
|
|
||||||
if p.artists:
|
|
||||||
self._add_event(encode_text_event(EventID.Artists, p.artists))
|
|
||||||
if p.comments:
|
|
||||||
self._add_event(encode_text_event(EventID.Comments, p.comments))
|
|
||||||
|
|
||||||
def _write_patterns(self):
|
|
||||||
p = self.project
|
|
||||||
for pat in p.patterns:
|
|
||||||
self._add_event(encode_word_event(EventID.PatNew, pat.index))
|
|
||||||
if pat.name:
|
|
||||||
self._add_event(encode_text_event(EventID.PatName, pat.name))
|
|
||||||
for ch_idx, notes in pat.notes.items():
|
|
||||||
if notes:
|
|
||||||
notes_data = encode_notes_block(
|
|
||||||
ch_idx,
|
|
||||||
[n.to_dict() if isinstance(n, Note) else n for n in notes],
|
|
||||||
ppq=p.ppq,
|
|
||||||
)
|
|
||||||
self._add_event(encode_data_event(EventID.PatNotes, notes_data))
|
|
||||||
|
|
||||||
def _write_channels(self):
|
|
||||||
p = self.project
|
|
||||||
for ch in p.channels:
|
|
||||||
self._add_event(encode_word_event(EventID.ChNew, ch.index))
|
|
||||||
self._add_event(encode_byte_event(EventID.ChType, ch.channel_type))
|
|
||||||
|
|
||||||
if ch.plugin:
|
|
||||||
self._add_event(
|
|
||||||
encode_text_event(EventID.PluginInternalName, ch.plugin.internal_name)
|
|
||||||
)
|
|
||||||
if ch.plugin.plugin_data:
|
|
||||||
self._add_event(
|
|
||||||
encode_data_event(EventID.PluginData, ch.plugin.plugin_data)
|
|
||||||
)
|
|
||||||
elif ch.plugin.internal_name == "Fruity Wrapper":
|
|
||||||
self._add_event(
|
|
||||||
encode_text_event(EventID.PluginName, ch.plugin.display_name)
|
|
||||||
)
|
|
||||||
wrapper_data = self._build_wrapper_stub(ch.plugin.display_name)
|
|
||||||
self._add_event(encode_data_event(EventID.PluginData, wrapper_data))
|
|
||||||
else:
|
|
||||||
self._add_event(
|
|
||||||
encode_text_event(EventID.PluginName, ch.plugin.display_name)
|
|
||||||
)
|
|
||||||
plugin_data = self._build_native_plugin_stub(ch.plugin.internal_name)
|
|
||||||
self._add_event(encode_data_event(EventID.PluginData, plugin_data))
|
|
||||||
|
|
||||||
if ch.plugin.color:
|
|
||||||
self._add_event(
|
|
||||||
encode_dword_event(EventID.PluginColor, ch.plugin.color)
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_event(encode_text_event(EventID.ChName, ch.name))
|
|
||||||
self._add_event(encode_byte_event(EventID.ChIsEnabled, 1 if ch.enabled else 0))
|
|
||||||
self._add_event(encode_byte_event(EventID.ChRoutedTo, ch.mixer_track & 0xFF))
|
|
||||||
self._add_event(encode_word_event(EventID.ChVolWord, ch.volume))
|
|
||||||
self._add_event(encode_byte_event(EventID.ChRootNote, ch.root_note))
|
|
||||||
|
|
||||||
def _build_wrapper_stub(self, plugin_name: str) -> bytes:
|
|
||||||
# Minimal VST wrapper state - FL Studio will initialize the plugin fresh
|
|
||||||
# 10 params with default values
|
|
||||||
stub = struct.pack("<II", 10, 1) # param_count=10, unknown=1
|
|
||||||
stub += struct.pack("<II", 20, 0) # version=20, flags=0
|
|
||||||
stub += b"\xff\xff\xff\xff\xff\xff\xff\xff" # GUID placeholder
|
|
||||||
stub += b"\x0c\x00\x0c\x00\x0c\x00\x0c\x00" # padding
|
|
||||||
stub += b"\x00" * 16 # zeros
|
|
||||||
return stub
|
|
||||||
|
|
||||||
def _build_native_plugin_stub(self, internal_name: str) -> bytes:
|
|
||||||
# Minimal native plugin state
|
|
||||||
stub = struct.pack("<II", 10, 1)
|
|
||||||
stub += struct.pack("<II", 20, 0)
|
|
||||||
stub += b"\xff\xff\xff\xff\xff\xff\xff\xff"
|
|
||||||
stub += b"\x0c\x00\x0c\x00\x0c\x00\x0c\x00"
|
|
||||||
stub += b"\x00" * 16
|
|
||||||
return stub
|
|
||||||
|
|
||||||
def _serialize(self) -> bytes:
|
|
||||||
num_channels = len(self.project.channels)
|
|
||||||
ppq = self.project.ppq
|
|
||||||
|
|
||||||
header = struct.pack(
|
|
||||||
"<4sIhHH",
|
|
||||||
b"FLhd",
|
|
||||||
6,
|
|
||||||
0,
|
|
||||||
num_channels,
|
|
||||||
ppq,
|
|
||||||
)
|
|
||||||
|
|
||||||
all_events = b"".join(self._events)
|
|
||||||
total_size = len(all_events)
|
|
||||||
|
|
||||||
data_header = b"FLdt" + struct.pack("<I", total_size)
|
|
||||||
|
|
||||||
return header + data_header + all_events
|
|
||||||
|
|
||||||
def write(self, filepath: str):
|
|
||||||
data = self.build()
|
|
||||||
with open(filepath, "wb") as f:
|
|
||||||
f.write(data)
|
|
||||||
return filepath
|
|
||||||
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
|
||||||
65
src/reaper_builder/render.py
Normal file
65
src/reaper_builder/render.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""REAPER project rendering — headless render to WAV via subprocess."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Default REAPER executable path on Windows
|
||||||
|
DEFAULT_REAPER_EXE = Path(r"C:\Program Files\REAPER (x64)\reaper.exe")
|
||||||
|
|
||||||
|
|
||||||
|
def render_project(
|
||||||
|
rpp_path: str | Path,
|
||||||
|
output_wav: str | Path,
|
||||||
|
reaper_exe: str | Path | None = None,
|
||||||
|
timeout_seconds: int = 120,
|
||||||
|
) -> None:
|
||||||
|
"""Render a .rpp project to WAV using the REAPER CLI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rpp_path: Path to the .rpp project file.
|
||||||
|
output_wav: Path where the rendered WAV will be written.
|
||||||
|
reaper_exe: Path to reaper.exe. Defaults to
|
||||||
|
``C:\\Program Files\\REAPER (x64)\\reaper.exe``.
|
||||||
|
timeout_seconds: Max seconds to wait for render to complete.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If reaper.exe is not found at the expected path
|
||||||
|
and no explicit path was provided.
|
||||||
|
RuntimeError: If the render process exits with a non-zero code
|
||||||
|
or is killed by the timeout.
|
||||||
|
"""
|
||||||
|
reaper_path = Path(reaper_exe) if reaper_exe else DEFAULT_REAPER_EXE
|
||||||
|
|
||||||
|
if not reaper_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"REAPER executable not found at: {reaper_path}\n"
|
||||||
|
"Install REAPER or provide an explicit reaper_exe path."
|
||||||
|
)
|
||||||
|
|
||||||
|
rpp_abs = str(Path(rpp_path).resolve())
|
||||||
|
wav_abs = str(Path(output_wav).resolve())
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
str(reaper_path),
|
||||||
|
"-nosplash",
|
||||||
|
"-render",
|
||||||
|
rpp_abs,
|
||||||
|
"-outfile",
|
||||||
|
wav_abs,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"REAPER render failed (exit {result.returncode}):\n"
|
||||||
|
f"stdout: {result.stdout}\n"
|
||||||
|
f"stderr: {result.stderr}"
|
||||||
|
)
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
FL_USER_DIR = Path(os.path.expanduser("~")) / "Documents" / "Image-Line" / "FL Studio"
|
|
||||||
PLUGIN_DB_DIR = FL_USER_DIR / "Presets" / "Plugin database" / "Installed"
|
|
||||||
PROJECT_ROOT = Path(os.path.expanduser("~")) / "Documents" / "fl_control"
|
|
||||||
|
|
||||||
|
|
||||||
def scan_installed_plugins() -> dict:
|
|
||||||
generators = []
|
|
||||||
effects = []
|
|
||||||
|
|
||||||
gen_dir = PLUGIN_DB_DIR / "Generators"
|
|
||||||
if gen_dir.exists():
|
|
||||||
for category_dir in gen_dir.iterdir():
|
|
||||||
if not category_dir.is_dir():
|
|
||||||
continue
|
|
||||||
category = category_dir.name
|
|
||||||
for fst_file in category_dir.glob("*.fst"):
|
|
||||||
name = fst_file.stem
|
|
||||||
generators.append({
|
|
||||||
"name": name,
|
|
||||||
"category": category,
|
|
||||||
"type": "generator",
|
|
||||||
"format": category,
|
|
||||||
"fst_path": str(fst_file),
|
|
||||||
})
|
|
||||||
|
|
||||||
fx_dir = PLUGIN_DB_DIR / "Effects"
|
|
||||||
if fx_dir.exists():
|
|
||||||
for category_dir in fx_dir.iterdir():
|
|
||||||
if not category_dir.is_dir():
|
|
||||||
continue
|
|
||||||
category = category_dir.name
|
|
||||||
for fst_file in category_dir.glob("*.fst"):
|
|
||||||
name = fst_file.stem
|
|
||||||
effects.append({
|
|
||||||
"name": name,
|
|
||||||
"category": category,
|
|
||||||
"type": "effect",
|
|
||||||
"format": category,
|
|
||||||
"fst_path": str(fst_file),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"generators": generators,
|
|
||||||
"effects": effects,
|
|
||||||
"generator_names": sorted(set(g["name"] for g in generators)),
|
|
||||||
"effect_names": sorted(set(e["name"] for e in effects)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def scan_samples(base_dir: Optional[Path] = None) -> dict:
|
|
||||||
if base_dir is None:
|
|
||||||
base_dir = PROJECT_ROOT / "librerias" / "organized_samples"
|
|
||||||
|
|
||||||
categories = {}
|
|
||||||
if not base_dir.exists():
|
|
||||||
return {"categories": {}, "total_files": 0}
|
|
||||||
|
|
||||||
for cat_dir in base_dir.iterdir():
|
|
||||||
if not cat_dir.is_dir():
|
|
||||||
continue
|
|
||||||
files = []
|
|
||||||
for f in cat_dir.rglob("*"):
|
|
||||||
if f.is_file() and f.suffix.lower() in (".wav", ".mp3", ".flac", ".ogg", ".aif", ".aiff"):
|
|
||||||
files.append({
|
|
||||||
"name": f.stem,
|
|
||||||
"path": str(f),
|
|
||||||
"size": f.stat().st_size,
|
|
||||||
"ext": f.suffix.lower(),
|
|
||||||
})
|
|
||||||
categories[cat_dir.name] = files
|
|
||||||
|
|
||||||
total = sum(len(v) for v in categories.values())
|
|
||||||
return {"categories": categories, "total_files": total}
|
|
||||||
|
|
||||||
|
|
||||||
def scan_library_packs(base_dir: Optional[Path] = None) -> dict:
|
|
||||||
if base_dir is None:
|
|
||||||
base_dir = PROJECT_ROOT / "librerias" / "reggaeton"
|
|
||||||
|
|
||||||
packs = []
|
|
||||||
if not base_dir.exists():
|
|
||||||
return {"packs": packs}
|
|
||||||
|
|
||||||
for pack_dir in base_dir.iterdir():
|
|
||||||
if not pack_dir.is_dir():
|
|
||||||
continue
|
|
||||||
pack = {
|
|
||||||
"name": pack_dir.name,
|
|
||||||
"path": str(pack_dir),
|
|
||||||
"contents": {},
|
|
||||||
}
|
|
||||||
for sub in pack_dir.rglob("*"):
|
|
||||||
if sub.is_dir():
|
|
||||||
continue
|
|
||||||
ext = sub.suffix.lower()
|
|
||||||
rel = str(sub.relative_to(pack_dir))
|
|
||||||
content_type = "other"
|
|
||||||
if ext in (".wav", ".mp3", ".flac", ".ogg", ".aif", ".aiff"):
|
|
||||||
content_type = "audio"
|
|
||||||
elif ext == ".mid":
|
|
||||||
content_type = "midi"
|
|
||||||
elif ext in (".fxp", ".fxb", ".fst"):
|
|
||||||
content_type = "preset"
|
|
||||||
|
|
||||||
if content_type not in pack["contents"]:
|
|
||||||
pack["contents"][content_type] = []
|
|
||||||
pack["contents"][content_type].append({
|
|
||||||
"name": sub.stem,
|
|
||||||
"path": str(sub),
|
|
||||||
"ext": ext,
|
|
||||||
"type": content_type,
|
|
||||||
})
|
|
||||||
|
|
||||||
packs.append(pack)
|
|
||||||
|
|
||||||
return {"packs": packs}
|
|
||||||
|
|
||||||
|
|
||||||
def scan_vector_store_metadata(vs_dir: Optional[Path] = None) -> dict:
|
|
||||||
if vs_dir is None:
|
|
||||||
vs_dir = PROJECT_ROOT / "librerias" / "vector_store"
|
|
||||||
|
|
||||||
metadata_path = vs_dir / "metadata.json"
|
|
||||||
if not metadata_path.exists():
|
|
||||||
return {"items": [], "total": 0}
|
|
||||||
|
|
||||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
types = {}
|
|
||||||
for item in data:
|
|
||||||
t = item.get("type", "unknown")
|
|
||||||
types[t] = types.get(t, 0) + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": len(data),
|
|
||||||
"types": types,
|
|
||||||
"items_with_key": sum(1 for i in data if i.get("key")),
|
|
||||||
"items_with_bpm": sum(1 for i in data if i.get("bpm")),
|
|
||||||
"sample_items": data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def full_inventory() -> dict:
|
|
||||||
plugins = scan_installed_plugins()
|
|
||||||
samples = scan_samples()
|
|
||||||
packs = scan_library_packs()
|
|
||||||
vector_store = scan_vector_store_metadata()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"plugins": plugins,
|
|
||||||
"samples": samples,
|
|
||||||
"packs": packs,
|
|
||||||
"vector_store": vector_store,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
inv = full_inventory()
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
"plugins": {
|
|
||||||
"generators": inv["plugins"]["generator_names"],
|
|
||||||
"effects": inv["plugins"]["effect_names"],
|
|
||||||
"total_generators": len(inv["plugins"]["generators"]),
|
|
||||||
"total_effects": len(inv["plugins"]["effects"]),
|
|
||||||
},
|
|
||||||
"samples": {
|
|
||||||
"categories": {k: len(v) for k, v in inv["samples"]["categories"].items()},
|
|
||||||
"total_files": inv["samples"]["total_files"],
|
|
||||||
},
|
|
||||||
"packs": [
|
|
||||||
{
|
|
||||||
"name": p["name"],
|
|
||||||
"audio_count": len(p["contents"].get("audio", [])),
|
|
||||||
"midi_count": len(p["contents"].get("midi", [])),
|
|
||||||
}
|
|
||||||
for p in inv["packs"]
|
|
||||||
],
|
|
||||||
"vector_store": {
|
|
||||||
"total": inv["vector_store"]["total"],
|
|
||||||
"types": inv["vector_store"]["types"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
||||||
170
tests/test_compose_integration.py
Normal file
170
tests/test_compose_integration.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Integration tests for scripts/compose.py — end-to-end compose workflow."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||||
|
from src.reaper_builder import RPPBuilder
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compose_via_builder(
|
||||||
|
genre: str = "reggaeton",
|
||||||
|
bpm: float = 95.0,
|
||||||
|
key: str = "Am",
|
||||||
|
output_path: str = "output/track.rpp",
|
||||||
|
) -> SongDefinition:
|
||||||
|
"""Build a SongDefinition the same way scripts/compose.py does, return it.
|
||||||
|
|
||||||
|
This lets us test the compose logic without hitting the filesystem for samples.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
genre_bar_map = {"reggaeton": 64, "trap": 32, "house": 64, "drill": 32}
|
||||||
|
bar_count = genre_bar_map.get(genre.lower(), 48)
|
||||||
|
|
||||||
|
# Drum tracks
|
||||||
|
drum_tracks = []
|
||||||
|
for role, generator_name in [
|
||||||
|
("kick", "kick_main_notes"),
|
||||||
|
("snare", "snare_verse_notes"),
|
||||||
|
("hihat", "hihat_16th_notes"),
|
||||||
|
("perc", "perc_combo_notes"),
|
||||||
|
]:
|
||||||
|
note_dict = get_notes(generator_name, bar_count)
|
||||||
|
midi_notes = rhythm_to_midi(note_dict)
|
||||||
|
clip = ClipDef(
|
||||||
|
position=0.0,
|
||||||
|
length=bar_count * 4.0,
|
||||||
|
name=f"{role.capitalize()} Pattern",
|
||||||
|
midi_notes=midi_notes,
|
||||||
|
)
|
||||||
|
drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip]))
|
||||||
|
|
||||||
|
# Melodic tracks (no selector — audio_path stays None)
|
||||||
|
for role, generator_fn in [
|
||||||
|
("bass", bass_tresillo),
|
||||||
|
("lead", lead_hook),
|
||||||
|
("chords", chords_block),
|
||||||
|
("pad", pad_sustain),
|
||||||
|
]:
|
||||||
|
note_list = generator_fn(key=key, bars=bar_count)
|
||||||
|
midi_notes = melodic_to_midi(note_list)
|
||||||
|
clip = ClipDef(
|
||||||
|
position=0.0,
|
||||||
|
length=bar_count * 4.0,
|
||||||
|
name=f"{role.capitalize()} MIDI",
|
||||||
|
midi_notes=midi_notes,
|
||||||
|
)
|
||||||
|
drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip]))
|
||||||
|
|
||||||
|
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
|
||||||
|
return SongDefinition(meta=meta, tracks=drum_tracks)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestComposeRppOutput:
|
||||||
|
"""Tests for compose workflow producing valid .rpp output."""
|
||||||
|
|
||||||
|
def test_compose_produces_rpp_file(self, tmp_path):
|
||||||
|
"""main() with valid args produces a .rpp file at the output path."""
|
||||||
|
output = tmp_path / "track.rpp"
|
||||||
|
|
||||||
|
# Mock SampleSelector.select_one so we don't need actual sample files
|
||||||
|
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
||||||
|
mock_selector = MagicMock()
|
||||||
|
mock_selector.select_one.return_value = None # audio_path stays None
|
||||||
|
mock_selector_cls.return_value = mock_selector
|
||||||
|
|
||||||
|
from scripts.compose import main
|
||||||
|
import sys
|
||||||
|
original_argv = sys.argv
|
||||||
|
try:
|
||||||
|
sys.argv = [
|
||||||
|
"compose",
|
||||||
|
"--genre", "reggaeton",
|
||||||
|
"--bpm", "95",
|
||||||
|
"--key", "Am",
|
||||||
|
"--output", str(output),
|
||||||
|
]
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
sys.argv = original_argv
|
||||||
|
|
||||||
|
assert output.exists(), f"Expected {output} to exist"
|
||||||
|
|
||||||
|
def test_compose_rpp_has_min_4_tracks(self, tmp_path):
|
||||||
|
"""The .rpp output contains at least 4 <TRACK blocks."""
|
||||||
|
output = tmp_path / "track.rpp"
|
||||||
|
|
||||||
|
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
|
||||||
|
mock_selector = MagicMock()
|
||||||
|
mock_selector.select_one.return_value = None
|
||||||
|
mock_selector_cls.return_value = mock_selector
|
||||||
|
|
||||||
|
from scripts.compose import main
|
||||||
|
import sys
|
||||||
|
original_argv = sys.argv
|
||||||
|
try:
|
||||||
|
sys.argv = [
|
||||||
|
"compose",
|
||||||
|
"--genre", "reggaeton",
|
||||||
|
"--bpm", "95",
|
||||||
|
"--key", "Am",
|
||||||
|
"--output", str(output),
|
||||||
|
]
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
sys.argv = original_argv
|
||||||
|
|
||||||
|
content = output.read_text(encoding="utf-8")
|
||||||
|
track_count = content.count("<TRACK")
|
||||||
|
assert track_count >= 4, f"Expected >= 4 tracks, got {track_count}"
|
||||||
|
|
||||||
|
def test_compose_invalid_bpm_raises(self):
|
||||||
|
"""main() with bpm=0 raises ValueError."""
|
||||||
|
from scripts.compose import main
|
||||||
|
import sys
|
||||||
|
original_argv = sys.argv
|
||||||
|
try:
|
||||||
|
sys.argv = [
|
||||||
|
"compose",
|
||||||
|
"--genre", "reggaeton",
|
||||||
|
"--bpm", "0",
|
||||||
|
"--key", "Am",
|
||||||
|
"--output", "output/track.rpp",
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="bpm must be > 0"):
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
sys.argv = original_argv
|
||||||
|
|
||||||
|
def test_compose_negative_bpm_raises(self):
|
||||||
|
"""main() with bpm=-10 raises ValueError."""
|
||||||
|
from scripts.compose import main
|
||||||
|
import sys
|
||||||
|
original_argv = sys.argv
|
||||||
|
try:
|
||||||
|
sys.argv = [
|
||||||
|
"compose",
|
||||||
|
"--genre", "reggaeton",
|
||||||
|
"--bpm", "-10",
|
||||||
|
"--key", "Am",
|
||||||
|
"--output", "output/track.rpp",
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="bpm must be > 0"):
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
sys.argv = original_argv
|
||||||
95
tests/test_converters.py
Normal file
95
tests/test_converters.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Tests for src/composer/converters.py — rhythm_to_midi, melodic_to_midi."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
||||||
|
from src.core.schema import MidiNote
|
||||||
|
|
||||||
|
|
||||||
|
class TestRhythmToMidi:
|
||||||
|
"""Tests for rhythm_to_midi() — channel → GM pitch mapping."""
|
||||||
|
|
||||||
|
def test_rhythm_to_midi_kick_channel(self):
|
||||||
|
"""Channel 11 (kick) maps to pitch 36 with correct start/duration/velocity."""
|
||||||
|
note_dict = {
|
||||||
|
11: [
|
||||||
|
{"pos": 0.0, "len": 0.25, "key": 36, "vel": 115},
|
||||||
|
{"pos": 1.0, "len": 0.25, "key": 36, "vel": 100},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = rhythm_to_midi(note_dict)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
# Pitch is resolved from CHANNEL_PITCH, not from the dict's "key"
|
||||||
|
assert result[0].pitch == 36
|
||||||
|
assert result[0].start == 0.0
|
||||||
|
assert result[0].duration == 0.25
|
||||||
|
assert result[0].velocity == 115
|
||||||
|
|
||||||
|
assert result[1].pitch == 36
|
||||||
|
assert result[1].start == 1.0
|
||||||
|
assert result[1].duration == 0.25
|
||||||
|
assert result[1].velocity == 100
|
||||||
|
|
||||||
|
def test_rhythm_to_midi_hihat_channel(self):
|
||||||
|
"""Channel 15 (hihat) maps to pitch 42."""
|
||||||
|
note_dict = {15: [{"pos": 0.0, "len": 0.125, "key": 42, "vel": 90}]}
|
||||||
|
result = rhythm_to_midi(note_dict)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].pitch == 42
|
||||||
|
assert result[0].start == 0.0
|
||||||
|
assert result[0].duration == 0.125
|
||||||
|
assert result[0].velocity == 90
|
||||||
|
|
||||||
|
def test_rhythm_to_midi_unknown_channel(self):
|
||||||
|
"""Unknown channel (not in CHANNEL_PITCH) defaults to pitch 60."""
|
||||||
|
note_dict = {99: [{"pos": 0.0, "len": 0.25, "key": 60, "vel": 100}]}
|
||||||
|
result = rhythm_to_midi(note_dict)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].pitch == 60 # default fallback
|
||||||
|
assert result[0].start == 0.0
|
||||||
|
|
||||||
|
def test_rhythm_to_midi_multi_channel(self):
|
||||||
|
"""3 different channels return a flat list with all notes combined."""
|
||||||
|
note_dict = {
|
||||||
|
11: [{"pos": 0.0, "len": 0.25, "key": 36, "vel": 115}],
|
||||||
|
15: [{"pos": 0.5, "len": 0.125, "key": 42, "vel": 90}],
|
||||||
|
10: [{"pos": 1.0, "len": 0.25, "key": 39, "vel": 80}],
|
||||||
|
}
|
||||||
|
result = rhythm_to_midi(note_dict)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
pitches = {n.pitch for n in result}
|
||||||
|
assert pitches == {36, 42, 39}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMelodicToMidi:
|
||||||
|
"""Tests for melodic_to_midi() — key field used directly as pitch."""
|
||||||
|
|
||||||
|
def test_melodic_to_midi_uses_key_as_pitch(self):
|
||||||
|
"""key=60 → pitch 60 (key field is used directly, not mapped)."""
|
||||||
|
note_list = [
|
||||||
|
{"pos": 0.0, "len": 0.5, "key": 60, "vel": 100},
|
||||||
|
{"pos": 0.5, "len": 0.5, "key": 64, "vel": 90},
|
||||||
|
{"pos": 1.0, "len": 0.5, "key": 67, "vel": 95},
|
||||||
|
]
|
||||||
|
result = melodic_to_midi(note_list)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].pitch == 60
|
||||||
|
assert result[1].pitch == 64
|
||||||
|
assert result[2].pitch == 67
|
||||||
|
assert result[0].start == 0.0
|
||||||
|
assert result[0].duration == 0.5
|
||||||
|
assert result[0].velocity == 100
|
||||||
|
|
||||||
|
def test_melodic_to_midi_empty_list(self):
|
||||||
|
"""Empty list returns empty list."""
|
||||||
|
result = melodic_to_midi([])
|
||||||
|
assert result == []
|
||||||
137
tests/test_core_schema.py
Normal file
137
tests/test_core_schema.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||||
|
|
||||||
|
|
||||||
|
class TestSongDefinitionInstantiation:
|
||||||
|
"""Test SongDefinition instantiation with valid data."""
|
||||||
|
|
||||||
|
def test_song_definition_with_valid_data(self):
|
||||||
|
"""SongDefinition instantiates with meta and tracks."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am", title="Test Song")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
assert song.meta.bpm == 95
|
||||||
|
assert song.meta.key == "Am"
|
||||||
|
assert song.meta.title == "Test Song"
|
||||||
|
assert song.tracks == []
|
||||||
|
|
||||||
|
def test_song_definition_with_multiple_tracks(self):
|
||||||
|
"""SongDefinition accepts multiple TrackDef entries."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
track1 = TrackDef(name="Kick", volume=0.85)
|
||||||
|
track2 = TrackDef(name="Bass", volume=0.80)
|
||||||
|
song = SongDefinition(meta=meta, tracks=[track1, track2])
|
||||||
|
assert len(song.tracks) == 2
|
||||||
|
assert song.tracks[0].name == "Kick"
|
||||||
|
assert song.tracks[1].name == "Bass"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackDefWithAudioClip:
|
||||||
|
"""Test TrackDef with audio clip (sample_path)."""
|
||||||
|
|
||||||
|
def test_track_with_audio_clip(self):
|
||||||
|
"""TrackDef with audio clip has audio_path set."""
|
||||||
|
clip = ClipDef(
|
||||||
|
position=0.0,
|
||||||
|
length=16.0,
|
||||||
|
name="Kick Loop",
|
||||||
|
audio_path="C:/samples/kick.wav",
|
||||||
|
)
|
||||||
|
track = TrackDef(name="Drums", clips=[clip])
|
||||||
|
assert len(track.clips) == 1
|
||||||
|
assert track.clips[0].is_audio
|
||||||
|
assert track.clips[0].audio_path == "C:/samples/kick.wav"
|
||||||
|
assert not track.clips[0].is_midi
|
||||||
|
|
||||||
|
def test_track_with_multiple_audio_clips(self):
|
||||||
|
"""TrackDef can hold multiple audio clips."""
|
||||||
|
clip1 = ClipDef(position=0.0, length=16.0, audio_path="C:/samples/kick.wav")
|
||||||
|
clip2 = ClipDef(position=16.0, length=16.0, audio_path="C:/samples/kick2.wav")
|
||||||
|
track = TrackDef(name="Drums", clips=[clip1, clip2])
|
||||||
|
assert len(track.clips) == 2
|
||||||
|
assert track.clips[0].audio_path == "C:/samples/kick.wav"
|
||||||
|
assert track.clips[1].audio_path == "C:/samples/kick2.wav"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClipDefWithMidiNotes:
|
||||||
|
"""Test ClipDef with MIDI notes."""
|
||||||
|
|
||||||
|
def test_clip_with_midi_notes(self):
|
||||||
|
"""ClipDef with midi_notes is identified as MIDI clip."""
|
||||||
|
note = MidiNote(pitch=36, start=0.0, duration=1.0, velocity=100)
|
||||||
|
clip = ClipDef(
|
||||||
|
position=0.0,
|
||||||
|
length=16.0,
|
||||||
|
name="Kick Pattern",
|
||||||
|
midi_notes=[note],
|
||||||
|
)
|
||||||
|
assert clip.is_midi
|
||||||
|
assert not clip.is_audio
|
||||||
|
assert len(clip.midi_notes) == 1
|
||||||
|
assert clip.midi_notes[0].pitch == 36
|
||||||
|
|
||||||
|
def test_clip_with_multiple_midi_notes(self):
|
||||||
|
"""ClipDef can hold multiple MidiNote entries."""
|
||||||
|
notes = [
|
||||||
|
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
|
||||||
|
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
|
||||||
|
MidiNote(pitch=38, start=2.0, duration=0.15, velocity=100),
|
||||||
|
]
|
||||||
|
clip = ClipDef(position=0.0, length=16.0, name="Drum Pattern", midi_notes=notes)
|
||||||
|
assert len(clip.midi_notes) == 3
|
||||||
|
assert clip.midi_notes[0].pitch == 36
|
||||||
|
assert clip.midi_notes[1].pitch == 36
|
||||||
|
assert clip.midi_notes[2].pitch == 38
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationNegativeBPM:
|
||||||
|
"""Test validation: negative BPM raises ValueError."""
|
||||||
|
|
||||||
|
def test_negative_bpm_raises_value_error(self):
|
||||||
|
"""SongDefinition.validate() returns error for negative BPM."""
|
||||||
|
meta = SongMeta(bpm=-10, key="Am")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
errors = song.validate()
|
||||||
|
assert any("bpm" in e.lower() for e in errors)
|
||||||
|
|
||||||
|
def test_zero_bpm_raises_value_error(self):
|
||||||
|
"""SongDefinition.validate() returns error for zero BPM."""
|
||||||
|
meta = SongMeta(bpm=0, key="Am")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
errors = song.validate()
|
||||||
|
assert any("bpm" in e.lower() for e in errors)
|
||||||
|
|
||||||
|
def test_valid_bpm_passes(self):
|
||||||
|
"""SongDefinition.validate() passes for BPM 20-999."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
errors = song.validate()
|
||||||
|
assert not any("bpm" in e.lower() for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMidiNote:
|
||||||
|
"""Test MidiNote dataclass."""
|
||||||
|
|
||||||
|
def test_midi_note_defaults(self):
|
||||||
|
"""MidiNote has sensible defaults for velocity."""
|
||||||
|
note = MidiNote(pitch=60, start=0.0, duration=1.0)
|
||||||
|
assert note.pitch == 60
|
||||||
|
assert note.start == 0.0
|
||||||
|
assert note.duration == 1.0
|
||||||
|
assert note.velocity == 64 # default
|
||||||
|
|
||||||
|
def test_midi_note_explicit_velocity(self):
|
||||||
|
"""MidiNote accepts explicit velocity."""
|
||||||
|
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=127)
|
||||||
|
assert note.velocity == 127
|
||||||
|
|
||||||
|
def test_midi_note_velocity_clamping(self):
|
||||||
|
"""MidiNote does NOT clamp — accepts any int (caller's responsibility)."""
|
||||||
|
note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200)
|
||||||
|
assert note.velocity == 200
|
||||||
176
tests/test_reaper_builder.py
Normal file
176
tests/test_reaper_builder.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Tests for src/reaper_builder/ — RPPBuilder, rpp_writer."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||||
|
from src.reaper_builder import RPPBuilder
|
||||||
|
|
||||||
|
|
||||||
|
class TestRPPBuilderWrite:
|
||||||
|
"""Test RPPBuilder.write() produces valid .rpp output."""
|
||||||
|
|
||||||
|
def test_write_produces_reaper_project_marker(self):
|
||||||
|
"""RPPBuilder.write() produces a file containing 'REAPER_PROJECT'."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am", title="Test")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "REAPER_PROJECT" in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_write_produces_tempo_line(self):
|
||||||
|
"""Output contains TEMPO line with correct BPM."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "TEMPO 95 " in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRPPBuilderAudioTrack:
|
||||||
|
"""Test audio track generates SOURCE WAVE block with correct file path."""
|
||||||
|
|
||||||
|
def test_audio_track_generates_source_wave_block(self):
|
||||||
|
"""Audio clip produces <SOURCE WAVE> block with FILE path."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
clip = ClipDef(
|
||||||
|
position=0.0,
|
||||||
|
length=16.0,
|
||||||
|
name="Kick Loop",
|
||||||
|
audio_path="C:/samples/kick.wav",
|
||||||
|
)
|
||||||
|
track = TrackDef(name="Drums", clips=[clip])
|
||||||
|
song = SongDefinition(meta=meta, tracks=[track])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "<SOURCE WAVE\n" in content
|
||||||
|
assert 'FILE C:/samples/kick.wav' in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_audio_track_includes_track_name(self):
|
||||||
|
"""Audio track block contains the track NAME."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
clip = ClipDef(position=0.0, length=16.0, audio_path="C:/kick.wav")
|
||||||
|
track = TrackDef(name="Kick", clips=[clip])
|
||||||
|
song = SongDefinition(meta=meta, tracks=[track])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "NAME Kick" in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRPPBuilderMidiTrack:
|
||||||
|
"""Test MIDI track generates MIDI event lines (E lines)."""
|
||||||
|
|
||||||
|
def test_midi_track_generates_e_lines(self):
|
||||||
|
"""MIDI clip produces E event lines in the output."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
note = MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115)
|
||||||
|
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=[note])
|
||||||
|
track = TrackDef(name="Drums", clips=[clip])
|
||||||
|
song = SongDefinition(meta=meta, tracks=[track])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "<SOURCE MIDI\n" in content
|
||||||
|
assert "E " in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_midi_track_note_on_off_pairs(self):
|
||||||
|
"""MIDI note produces note-on (90) and note-off (80) E lines."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
notes = [
|
||||||
|
MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115),
|
||||||
|
MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105),
|
||||||
|
]
|
||||||
|
clip = ClipDef(position=0.0, length=16.0, name="Kick Pattern", midi_notes=notes)
|
||||||
|
track = TrackDef(name="Drums", clips=[clip])
|
||||||
|
song = SongDefinition(meta=meta, tracks=[track])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
# Note on: status 90, pitch, velocity
|
||||||
|
assert "90" in content
|
||||||
|
# Note off: status 80, pitch, 00
|
||||||
|
assert "80" in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRPPBuilderMasterTrack:
|
||||||
|
"""Test that RPPBuilder includes a master track."""
|
||||||
|
|
||||||
|
def test_output_contains_master_track(self):
|
||||||
|
"""Generated .rpp contains a master track."""
|
||||||
|
meta = SongMeta(bpm=95, key="Am")
|
||||||
|
song = SongDefinition(meta=meta, tracks=[])
|
||||||
|
builder = RPPBuilder(song)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder.write(tmp_path)
|
||||||
|
content = Path(tmp_path).read_text(encoding="utf-8")
|
||||||
|
assert "NAME master" in content
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
105
tests/test_render.py
Normal file
105
tests/test_render.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for src/reaper_builder/render.py — render_project."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from src.reaper_builder.render import render_project
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderProjectFileNotFound:
|
||||||
|
"""Test render_project raises FileNotFoundError when reaper.exe path doesn't exist."""
|
||||||
|
|
||||||
|
def test_render_project_raises_file_not_found_for_nonexistent_reaper_exe(self):
|
||||||
|
"""FileNotFoundError is raised when reaper.exe does not exist."""
|
||||||
|
nonexistent = Path("C:/Program Files/NONEXISTENT_REAPER/reaper.exe")
|
||||||
|
if nonexistent.exists():
|
||||||
|
pytest.skip("Nonexistent path actually exists — cannot test this")
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
render_project(
|
||||||
|
rpp_path="input.rpp",
|
||||||
|
output_wav="output.wav",
|
||||||
|
reaper_exe=nonexistent,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_render_project_raises_file_not_found_default_path(self):
|
||||||
|
"""FileNotFoundError raised when default reaper.exe doesn't exist and none provided."""
|
||||||
|
# Patch DEFAULT_REAPER_EXE to a definitely-nonexistent path
|
||||||
|
with patch("src.reaper_builder.render.DEFAULT_REAPER_EXE", Path("/no/such/reaper.exe")):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
render_project(
|
||||||
|
rpp_path="input.rpp",
|
||||||
|
output_wav="output.wav",
|
||||||
|
reaper_exe=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderProjectSubprocessError:
|
||||||
|
"""Test render_project raises RuntimeError when subprocess returns non-zero exit code."""
|
||||||
|
|
||||||
|
def test_render_project_raises_runtime_error_on_nonzero_exit(self):
|
||||||
|
"""RuntimeError is raised when subprocess.run returns non-zero returncode."""
|
||||||
|
from src.reaper_builder.render import DEFAULT_REAPER_EXE
|
||||||
|
|
||||||
|
# Check if reaper exists — if not, skip
|
||||||
|
if not DEFAULT_REAPER_EXE.exists():
|
||||||
|
pytest.skip(f"REAPER not installed at {DEFAULT_REAPER_EXE}")
|
||||||
|
|
||||||
|
# Create a minimal valid .rpp for this test
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".rpp", delete=False, encoding="utf-8") as f:
|
||||||
|
rpp_path = f.name
|
||||||
|
f.write('<REAPER_PROJECT 0.1 "6.0" 0\n>\n')
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||||
|
wav_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Mock subprocess.run to return non-zero
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 1
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = "Test error"
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
render_project(rpp_path=rpp_path, output_wav=wav_path)
|
||||||
|
assert "1" in str(exc_info.value) or "failed" in str(exc_info.value).lower()
|
||||||
|
finally:
|
||||||
|
Path(rpp_path).unlink(missing_ok=True)
|
||||||
|
Path(wav_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_render_project_raises_runtime_error_with_error_message(self):
|
||||||
|
"""RuntimeError output includes stdout/stderr from REAPER failure."""
|
||||||
|
from src.reaper_builder.render import DEFAULT_REAPER_EXE
|
||||||
|
|
||||||
|
if not DEFAULT_REAPER_EXE.exists():
|
||||||
|
pytest.skip(f"REAPER not installed at {DEFAULT_REAPER_EXE}")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".rpp", delete=False, encoding="utf-8") as f:
|
||||||
|
rpp_path = f.name
|
||||||
|
f.write('<REAPER_PROJECT 0.1 "6.0" 0\n>\n')
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||||
|
wav_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 2
|
||||||
|
mock_result.stdout = "standard output text"
|
||||||
|
mock_result.stderr = "error output text"
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
render_project(rpp_path=rpp_path, output_wav=wav_path)
|
||||||
|
# Error message should include the exit code or stderr
|
||||||
|
err_str = str(exc_info.value)
|
||||||
|
assert "2" in err_str or "error output text" in err_str
|
||||||
|
finally:
|
||||||
|
Path(rpp_path).unlink(missing_ok=True)
|
||||||
|
Path(wav_path).unlink(missing_ok=True)
|
||||||
127
tests/test_render_cli.py
Normal file
127
tests/test_render_cli.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Tests for scripts/compose.py render CLI flags."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import argparse
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from scripts.compose import main as compose_main
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderFlag:
|
||||||
|
"""Test --render and --render-output CLI arguments."""
|
||||||
|
|
||||||
|
def test_render_flag_defaults_to_false(self):
|
||||||
|
"""Without --render, the render flag should be False."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--render", action="store_true")
|
||||||
|
parser.add_argument("--render-output", default=None)
|
||||||
|
|
||||||
|
args = parser.parse_args([])
|
||||||
|
assert args.render is False
|
||||||
|
|
||||||
|
def test_render_flag_true_when_provided(self):
|
||||||
|
"""With --render, the flag should be True."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--render", action="store_true")
|
||||||
|
parser.add_argument("--render-output", default=None)
|
||||||
|
|
||||||
|
args = parser.parse_args(["--render"])
|
||||||
|
assert args.render is True
|
||||||
|
|
||||||
|
def test_render_output_defaults_to_none(self):
|
||||||
|
"""--render-output defaults to None when not provided."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--render", action="store_true")
|
||||||
|
parser.add_argument("--render-output", default=None)
|
||||||
|
|
||||||
|
args = parser.parse_args([])
|
||||||
|
assert args.render_output is None
|
||||||
|
|
||||||
|
@patch("scripts.compose.render_project")
|
||||||
|
@patch("scripts.compose.RPPBuilder")
|
||||||
|
def test_render_triggers_render_project_call(self, mock_builder_cls, mock_render):
|
||||||
|
"""Calling main with --render invokes render_project."""
|
||||||
|
mock_builder = MagicMock()
|
||||||
|
mock_builder_cls.return_value = mock_builder
|
||||||
|
|
||||||
|
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||||
|
mock_sel_instance = MagicMock()
|
||||||
|
mock_sel_instance.select_one.return_value = None
|
||||||
|
mock_selector.return_value = mock_sel_instance
|
||||||
|
|
||||||
|
with patch("sys.argv", ["compose.py", "--genre", "reggaeton", "--render"]):
|
||||||
|
compose_main()
|
||||||
|
|
||||||
|
mock_render.assert_called_once()
|
||||||
|
call_args = mock_render.call_args
|
||||||
|
# First arg should be the .rpp path, second should be .wav path
|
||||||
|
rpp_path = call_args[0][0]
|
||||||
|
wav_path = call_args[0][1]
|
||||||
|
assert rpp_path.endswith(".rpp")
|
||||||
|
assert wav_path.endswith(".wav")
|
||||||
|
|
||||||
|
@patch("scripts.compose.render_project")
|
||||||
|
@patch("scripts.compose.RPPBuilder")
|
||||||
|
def test_without_render_does_not_call_render_project(self, mock_builder_cls, mock_render):
|
||||||
|
"""Calling main without --render does NOT invoke render_project."""
|
||||||
|
mock_builder = MagicMock()
|
||||||
|
mock_builder_cls.return_value = mock_builder
|
||||||
|
|
||||||
|
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||||
|
mock_sel_instance = MagicMock()
|
||||||
|
mock_sel_instance.select_one.return_value = None
|
||||||
|
mock_selector.return_value = mock_sel_instance
|
||||||
|
|
||||||
|
with patch("sys.argv", ["compose.py", "--genre", "reggaeton"]):
|
||||||
|
compose_main()
|
||||||
|
|
||||||
|
mock_render.assert_not_called()
|
||||||
|
|
||||||
|
@patch("scripts.compose.render_project")
|
||||||
|
@patch("scripts.compose.RPPBuilder")
|
||||||
|
def test_render_output_overrides_default_wav_path(self, mock_builder_cls, mock_render):
|
||||||
|
"""--render-output sets the WAV path explicitly."""
|
||||||
|
mock_builder = MagicMock()
|
||||||
|
mock_builder_cls.return_value = mock_builder
|
||||||
|
|
||||||
|
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||||
|
mock_sel_instance = MagicMock()
|
||||||
|
mock_sel_instance.select_one.return_value = None
|
||||||
|
mock_selector.return_value = mock_sel_instance
|
||||||
|
|
||||||
|
with patch("sys.argv", [
|
||||||
|
"compose.py",
|
||||||
|
"--genre", "reggaeton",
|
||||||
|
"--render",
|
||||||
|
"--render-output", "output/my_render.wav"
|
||||||
|
]):
|
||||||
|
compose_main()
|
||||||
|
|
||||||
|
mock_render.assert_called_once()
|
||||||
|
wav_path = mock_render.call_args[0][1]
|
||||||
|
assert wav_path == "output/my_render.wav"
|
||||||
|
|
||||||
|
@patch("scripts.compose.render_project")
|
||||||
|
@patch("scripts.compose.RPPBuilder")
|
||||||
|
def test_render_project_propagates_file_not_found_error(self, mock_builder_cls, mock_render):
|
||||||
|
"""FileNotFoundError from render_project is propagated to caller."""
|
||||||
|
mock_builder = MagicMock()
|
||||||
|
mock_builder_cls.return_value = mock_builder
|
||||||
|
mock_render.side_effect = FileNotFoundError("reaper.exe not found")
|
||||||
|
|
||||||
|
with patch("scripts.compose.SampleSelector") as mock_selector:
|
||||||
|
mock_sel_instance = MagicMock()
|
||||||
|
mock_sel_instance.select_one.return_value = None
|
||||||
|
mock_selector.return_value = mock_sel_instance
|
||||||
|
|
||||||
|
with patch("sys.argv", [
|
||||||
|
"compose.py",
|
||||||
|
"--genre", "reggaeton",
|
||||||
|
"--render",
|
||||||
|
]):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
compose_main()
|
||||||
Reference in New Issue
Block a user