refactor: migrate from FL Studio to REAPER with rpp library

Replace FL Studio binary .flp output with REAPER text-based .rpp output
using the rpp Python library (Perlence/rpp).

- Add core/schema.py: DAW-agnostic data types (SongDefinition, TrackDef,
  ClipDef, MidiNote, PluginDef)
- Add reaper_builder/: RPP file generation via rpp.Element + headless
  render via reaper.exe CLI
- Add composer/converters.py: bridge rhythm.py/melodic.py note dicts
  to core.schema MidiNote objects
- Rewrite scripts/compose.py: real generator pipeline with --render flag
- Delete src/flp_builder/, src/scanner/, mcp/, flstudio-mcp/, old scripts
- Add 40 passing tests (schema, builder, converters, compose, render)
This commit is contained in:
renato97
2026-05-03 09:13:35 -03:00
parent 1e2316a5a4
commit af6d61c8a1
47 changed files with 1589 additions and 4990 deletions

View File

@@ -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

View File

@@ -1,5 +0,0 @@
"""FL Studio MCP Server — nibble-encoded SysEx over MIDI loopback."""
from __future__ import annotations
__version__ = "0.1.0"

View File

@@ -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",
]

View File

@@ -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"))

View File

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

View File

@@ -1,5 +0,0 @@
"""FL Studio MCP Server entry point."""
from server import mcp
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

@@ -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")

View File

@@ -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!")

View File

@@ -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.")

View File

@@ -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!")

View File

@@ -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.

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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__":

View File

@@ -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}")

View File

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

View File

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

View 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
]

View File

@@ -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.

View File

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

View File

@@ -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
View File

253
src/core/schema.py Normal file
View 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 # 20999
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 0127 (60 = middle C)
start: Start time in beats (from start of item)
duration: Duration in beats
velocity: 0127
"""
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.851.1)
density: Note density 0.01.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.01.0 (maps to REAPER volume fader)
pan: -1.0 to 1.0
color: REAPER color index (067), 0 = default
clips: Audio/MIDI clips placed on this track
plugins: VST plugins on this track
send_reverb: Reverb send level 0.01.0
send_delay: Delay send level 0.01.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 20999, 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)

View File

@@ -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",
]

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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 # 20999
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 0127
@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 (1016)
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 0127
@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.01.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 20999
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 20999, 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)

View File

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

View File

@@ -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

View File

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

View 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}"
)

View File

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

View 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
View 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
View 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

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