From af6d61c8a1f437fd478130ca9d9ca2c4cf877265 Mon Sep 17 00:00:00 2001 From: renato97 Date: Sun, 3 May 2026 09:13:35 -0300 Subject: [PATCH] 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) --- COMPONER.bat | 3 - flstudio-mcp | 1 - mcp/__init__.py | 5 - mcp/protocol/__init__.py | 15 - mcp/protocol/sysex.py | 65 --- mcp/protocol/transport.py | 67 --- mcp/run.py | 5 - mcp/server.py | 356 ---------------- mcp/tests/quick_test.py | 26 -- mcp/tests/send_ping.py | 46 -- mcp/tests/test_integration.py | 125 ------ mcp/tests/test_sysex_loopback.py | 199 --------- output/flstudio_sampler_template.bin | Bin 960 -> 0 bytes requirements.txt | 1 + scripts/__init__.py | 0 scripts/batch_generate.py | 122 ------ scripts/build.py | 160 ------- scripts/build_complete_reggaeton.py | 436 ------------------- scripts/build_from_json.py | 42 -- scripts/build_reggaeton_fuego.py | 610 --------------------------- scripts/compose.py | 244 ++++++++--- scripts/compose_full_track.py | 239 ----------- scripts/compose_track.py | 251 ----------- scripts/inventory.py | 47 --- src/composer/converters.py | 65 +++ src/composer/melodic.py | 24 +- src/composer/rhythm.py | 60 ++- src/composer/variation.py | 2 +- src/core/__init__.py | 0 src/core/schema.py | 253 +++++++++++ src/flp_builder/__init__.py | 12 - src/flp_builder/arrangement.py | 222 ---------- src/flp_builder/builder.py | 382 ----------------- src/flp_builder/events.py | 225 ---------- src/flp_builder/project.py | 134 ------ src/flp_builder/schema.py | 395 ----------------- src/flp_builder/skeleton.py | 382 ----------------- src/flp_builder/writer.py | 145 ------- src/reaper_builder/__init__.py | 144 +++++++ src/reaper_builder/render.py | 65 +++ src/scanner/__init__.py | 194 --------- tests/test_compose_integration.py | 170 ++++++++ tests/test_converters.py | 95 +++++ tests/test_core_schema.py | 137 ++++++ tests/test_reaper_builder.py | 176 ++++++++ tests/test_render.py | 105 +++++ tests/test_render_cli.py | 127 ++++++ 47 files changed, 1589 insertions(+), 4990 deletions(-) delete mode 100644 COMPONER.bat delete mode 160000 flstudio-mcp delete mode 100644 mcp/__init__.py delete mode 100644 mcp/protocol/__init__.py delete mode 100644 mcp/protocol/sysex.py delete mode 100644 mcp/protocol/transport.py delete mode 100644 mcp/run.py delete mode 100644 mcp/server.py delete mode 100644 mcp/tests/quick_test.py delete mode 100644 mcp/tests/send_ping.py delete mode 100644 mcp/tests/test_integration.py delete mode 100644 mcp/tests/test_sysex_loopback.py delete mode 100644 output/flstudio_sampler_template.bin create mode 100644 scripts/__init__.py delete mode 100644 scripts/batch_generate.py delete mode 100644 scripts/build.py delete mode 100644 scripts/build_complete_reggaeton.py delete mode 100644 scripts/build_from_json.py delete mode 100644 scripts/build_reggaeton_fuego.py delete mode 100644 scripts/compose_full_track.py delete mode 100644 scripts/compose_track.py delete mode 100644 scripts/inventory.py create mode 100644 src/composer/converters.py create mode 100644 src/core/__init__.py create mode 100644 src/core/schema.py delete mode 100644 src/flp_builder/__init__.py delete mode 100644 src/flp_builder/arrangement.py delete mode 100644 src/flp_builder/builder.py delete mode 100644 src/flp_builder/events.py delete mode 100644 src/flp_builder/project.py delete mode 100644 src/flp_builder/schema.py delete mode 100644 src/flp_builder/skeleton.py delete mode 100644 src/flp_builder/writer.py create mode 100644 src/reaper_builder/__init__.py create mode 100644 src/reaper_builder/render.py delete mode 100644 src/scanner/__init__.py create mode 100644 tests/test_compose_integration.py create mode 100644 tests/test_converters.py create mode 100644 tests/test_core_schema.py create mode 100644 tests/test_reaper_builder.py create mode 100644 tests/test_render.py create mode 100644 tests/test_render_cli.py diff --git a/COMPONER.bat b/COMPONER.bat deleted file mode 100644 index c47545d..0000000 --- a/COMPONER.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -python scripts\compose_track.py --key Am --bpm 95 --bars 8 --output output\reggaeton.flp -pause \ No newline at end of file diff --git a/flstudio-mcp b/flstudio-mcp deleted file mode 160000 index d518dec..0000000 --- a/flstudio-mcp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d518dec361700d886ffc74eb94d388636a2eeed8 diff --git a/mcp/__init__.py b/mcp/__init__.py deleted file mode 100644 index 242706f..0000000 --- a/mcp/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""FL Studio MCP Server — nibble-encoded SysEx over MIDI loopback.""" - -from __future__ import annotations - -__version__ = "0.1.0" diff --git a/mcp/protocol/__init__.py b/mcp/protocol/__init__.py deleted file mode 100644 index 75c0963..0000000 --- a/mcp/protocol/__init__.py +++ /dev/null @@ -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", -] diff --git a/mcp/protocol/sysex.py b/mcp/protocol/sysex.py deleted file mode 100644 index fe9659b..0000000 --- a/mcp/protocol/sysex.py +++ /dev/null @@ -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")) diff --git a/mcp/protocol/transport.py b/mcp/protocol/transport.py deleted file mode 100644 index 71424ac..0000000 --- a/mcp/protocol/transport.py +++ /dev/null @@ -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()} diff --git a/mcp/run.py b/mcp/run.py deleted file mode 100644 index ea95ae8..0000000 --- a/mcp/run.py +++ /dev/null @@ -1,5 +0,0 @@ -"""FL Studio MCP Server entry point.""" -from server import mcp - -if __name__ == "__main__": - mcp.run(transport="stdio") diff --git a/mcp/server.py b/mcp/server.py deleted file mode 100644 index 3533424..0000000 --- a/mcp/server.py +++ /dev/null @@ -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") diff --git a/mcp/tests/quick_test.py b/mcp/tests/quick_test.py deleted file mode 100644 index 772f757..0000000 --- a/mcp/tests/quick_test.py +++ /dev/null @@ -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!") diff --git a/mcp/tests/send_ping.py b/mcp/tests/send_ping.py deleted file mode 100644 index f078961..0000000 --- a/mcp/tests/send_ping.py +++ /dev/null @@ -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.") diff --git a/mcp/tests/test_integration.py b/mcp/tests/test_integration.py deleted file mode 100644 index dd30f67..0000000 --- a/mcp/tests/test_integration.py +++ /dev/null @@ -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!") diff --git a/mcp/tests/test_sysex_loopback.py b/mcp/tests/test_sysex_loopback.py deleted file mode 100644 index 2c07851..0000000 --- a/mcp/tests/test_sysex_loopback.py +++ /dev/null @@ -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) diff --git a/output/flstudio_sampler_template.bin b/output/flstudio_sampler_template.bin deleted file mode 100644 index 175bc6b0d0b1795c3687737fea953b6131186553..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 960 zcmb_b&nrYx6#m{AMK+U$*v)2&l%~m2v6vx7Ml(_LHiL&j$jq?4#fk;iQ?j$O^cUDj zDW%lNZtN|r@tu3$eMVL^=XLM-&Ub#i^X@x$v;$qZ4FQis@a2;taE7bJ=d4;FxUvTI zU1tVGEMWsN^dX6Ua#isw*INgiho~6n)jPser(8!03deL~9w;cJ73ScKBb)~&fy_Oo zfLXL1unxw6?$C3O)QAq4I!p}Y)x04Y5E{I+i+Y#1r*p@C5iN~}#R)a?dr41#j%Tw8 z9p~V(RRiPnp;Zm7SPDE&d`J;WKnCEA?=_7`EI5;5G1Mc=Vl_xTVp{&DYAK5) zHa2Zmq5}Q|gT-%sg0GeAKc$um`-F{@N`I+R$`OE!po=I^n|FWfnOSO5S3 diff --git a/requirements.txt b/requirements.txt index a3f0e0b..ce6ade1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ scipy>=1.11.0 soundfile>=0.12.0 mido>=1.3.0 fastmcp>=0.1.0 +rpp>=0.5 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/batch_generate.py b/scripts/batch_generate.py deleted file mode 100644 index 11dd346..0000000 --- a/scripts/batch_generate.py +++ /dev/null @@ -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() diff --git a/scripts/build.py b/scripts/build.py deleted file mode 100644 index 712e717..0000000 --- a/scripts/build.py +++ /dev/null @@ -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() diff --git a/scripts/build_complete_reggaeton.py b/scripts/build_complete_reggaeton.py deleted file mode 100644 index 2145195..0000000 --- a/scripts/build_complete_reggaeton.py +++ /dev/null @@ -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(" 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() diff --git a/scripts/build_from_json.py b/scripts/build_from_json.py deleted file mode 100644 index aa98116..0000000 --- a/scripts/build_from_json.py +++ /dev/null @@ -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 [--out ] -""" -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() diff --git a/scripts/build_reggaeton_fuego.py b/scripts/build_reggaeton_fuego.py deleted file mode 100644 index a936624..0000000 --- a/scripts/build_reggaeton_fuego.py +++ /dev/null @@ -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(" 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(" TrackDef: + """Build a drum MIDI track from a rhythm generator. + + Args: + role: Track name (e.g. "kick", "snare") + generator_name: Name from rhythm.GENERATORS (e.g. "kick_main_notes") + bars: Number of bars + """ + note_dict = get_notes(generator_name, bars) + midi_notes = rhythm_to_midi(note_dict) + clip = ClipDef( + position=0.0, + length=bars * 4.0, + name=f"{role.capitalize()} Pattern", + midi_notes=midi_notes, + ) + return TrackDef(name=role.capitalize(), clips=[clip]) + + +def build_melodic_track( + role: str, + generator_fn, + key: str, + bpm: float, + bars: int, + selector: SampleSelector | None = None, +) -> TrackDef: + """Build a melodic MIDI track from a generator function. + + Args: + role: Track name (e.g. "bass", "lead") + generator_fn: Callable from melodic.py (e.g. bass_tresillo) + key: Musical key (e.g. "Am") + bpm: Tempo for sample selection + bars: Number of bars + selector: Optional SampleSelector; if provided, sets audio_path on ClipDef + """ + note_list = generator_fn(key=key, bars=bars) + midi_notes = melodic_to_midi(note_list) + + audio_path: str | None = None + if selector is not None: + match = selector.select_one(role=role, key=key, bpm=bpm) + if match: + audio_path = match.get("original_path", None) + + clip = ClipDef( + position=0.0, + length=bars * 4.0, + name=f"{role.capitalize()} MIDI", + audio_path=audio_path, + midi_notes=midi_notes, + ) + return TrackDef(name=role.capitalize(), clips=[clip]) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Compose a REAPER .rpp project from the sample library." + ) + parser.add_argument( + "--genre", + default="reggaeton", + help="Genre (default: reggaeton)", + ) + parser.add_argument( + "--bpm", + type=float, + default=95.0, + help="BPM (default: 95)", + ) + parser.add_argument( + "--key", + default="Am", + help="Musical key (default: Am)", + ) + parser.add_argument( + "--output", + default="output/track.rpp", + help="Output .rpp path (default: output/track.rpp)", + ) + parser.add_argument( + "--render", + action="store_true", + help="Render the project to WAV after generating the .rpp file.", + ) + parser.add_argument( + "--render-output", + default=None, + help="Output WAV path for rendering. Defaults to .wav with .rpp extension replaced.", + ) args = parser.parse_args() - genre_file = KNOWLEDGE_DIR / f"{args.genre}.json" - if not genre_file.exists(): - print(json.dumps({"error": f"Genre not found: {genre_file}", "available": [p.stem for p in KNOWLEDGE_DIR.glob("*.json")]})) + # Validate BPM before any writes + if args.bpm <= 0: + raise ValueError(f"bpm must be > 0, got {args.bpm}") + + # Ensure output directory exists + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Load sample index (for melodic tracks that use audio samples) + index_path = _ROOT / "data" / "sample_index.json" + if not index_path.exists(): + print(f"ERROR: sample index not found at {index_path}", file=sys.stderr) sys.exit(1) - overrides = {} - if args.key: - overrides["keys"] = [args.key] - if args.bpm: - overrides["bpm"] = {"default": args.bpm} - if args.bars: - overrides["structure"] = {"sections": [{"bars": args.bars}]} + selector = SampleSelector(str(index_path)) - composition = compose_from_genre(str(genre_file), overrides if overrides else None) - project = build_project(composition) - - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - output_path = args.output or str( - OUTPUT_DIR / f"{args.genre}_{composition['meta']['key']}_{composition['meta']['bpm']}bpm.flp" - ) - - 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", [])), + # Determine bar count from genre + genre_bar_map = { + "reggaeton": 64, + "trap": 32, + "house": 64, + "drill": 32, } - 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__": - main() + main() \ No newline at end of file diff --git a/scripts/compose_full_track.py b/scripts/compose_full_track.py deleted file mode 100644 index 3d4e922..0000000 --- a/scripts/compose_full_track.py +++ /dev/null @@ -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}") \ No newline at end of file diff --git a/scripts/compose_track.py b/scripts/compose_track.py deleted file mode 100644 index a159c17..0000000 --- a/scripts/compose_track.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/scripts/inventory.py b/scripts/inventory.py deleted file mode 100644 index 3ae5977..0000000 --- a/scripts/inventory.py +++ /dev/null @@ -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() diff --git a/src/composer/converters.py b/src/composer/converters.py new file mode 100644 index 0000000..5451db0 --- /dev/null +++ b/src/composer/converters.py @@ -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 + ] \ No newline at end of file diff --git a/src/composer/melodic.py b/src/composer/melodic.py index e034127..daf9b1c 100644 --- a/src/composer/melodic.py +++ b/src/composer/melodic.py @@ -4,6 +4,8 @@ All generators return list[dict] with format {pos, len, key, vel}. Designed to feed MelodicTrack notes in SongDefinition. """ +import random + # --------------------------------------------------------------------------- # Scale definitions # --------------------------------------------------------------------------- @@ -52,6 +54,18 @@ def _clamp_vel(v: int) -> int: 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 # --------------------------------------------------------------------------- @@ -61,6 +75,7 @@ def bass_tresillo( bars: int, octave: int = 3, velocity_mult: float = 1.0, + humanize: float = 0.0, ) -> list[dict]: """Reggaeton tresillo bass pattern. @@ -90,7 +105,7 @@ def bass_tresillo( vel = _clamp_vel(int(vel * velocity_mult)) 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, density: float = 0.6, velocity_mult: float = 1.0, + humanize: float = 0.0, ) -> list[dict]: """Simple melodic hook over 4-8 bars. @@ -154,7 +170,7 @@ def lead_hook( else: pos += 0.5 - return notes + return _apply_humanize(notes, humanize) # --------------------------------------------------------------------------- @@ -166,6 +182,7 @@ def chords_block( bars: int, octave: int = 4, velocity_mult: float = 1.0, + humanize: float = 0.0, ) -> list[dict]: """Blocked chords every 2 beats (half-bar). @@ -231,7 +248,7 @@ def chords_block( "vel": vel, }) - return notes + return _apply_humanize(notes, humanize) # --------------------------------------------------------------------------- @@ -243,6 +260,7 @@ def pad_sustain( bars: int, octave: int = 4, velocity_mult: float = 1.0, + humanize: float = 0.0, ) -> list[dict]: """Long sustained pad notes, one per bar. diff --git a/src/composer/rhythm.py b/src/composer/rhythm.py index bf33270..58bd369 100644 --- a/src/composer/rhythm.py +++ b/src/composer/rhythm.py @@ -1,5 +1,7 @@ """Reggaeton rhythm generators — pure functions returning note dicts per channel.""" +import random + # --------------------------------------------------------------------------- # Channel constants — match SAMPLE_MAP in channel_skeleton.py # --------------------------------------------------------------------------- @@ -21,6 +23,20 @@ CH_CL = 16 # clap.wav # 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: """Clamp velocity to valid MIDI range [1, 127].""" return max(1, min(127, vel)) @@ -44,6 +60,7 @@ def kick_main_notes( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """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 notes.append(_note(o, 0.25, _apply_vel(115, 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """Sparse intro/outro kick: just beat 1 per bar (vel 110). @@ -71,20 +89,21 @@ def kick_sparse_notes( for b in range(bars): o = b * 4.0 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """Outro kick: dembow pattern with 0.75 baseline softness. Delegates to kick_main_notes with an additional 0.75 velocity scaling. 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, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """Reggaeton snare: beats 2, 3, 3-and, 4 per bar. @@ -107,13 +127,14 @@ def snare_verse_notes( o = b * 4.0 for p, v in _PATTERN: 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """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 for p, v in _PATTERN: 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """Softer outro snare (velocity_mult on top of 0.7 baseline). Delegates to snare_verse_notes with an additional 0.7 velocity scaling. 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, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """16th-note hihat with three-tier accent mapping. @@ -177,13 +200,14 @@ def hihat_16th_notes( else: # 16th note position base_vel = 40 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """8th-note hihat for intro/breakdown. @@ -196,17 +220,14 @@ def hihat_8th_notes( for i in range(8): base_vel = 70 if i % 2 == 0 else 50 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """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 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))) - return {CH_CL: notes} + return _apply_groove({CH_CL: notes}, groove_strength) -# --------------------------------------------------------------------------- -# Percussion generators -# --------------------------------------------------------------------------- - def perc_combo_notes( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """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))) 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))) - 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( bars: int, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """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) for idx in _PATTERNS[cycle]: 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, velocity_mult: float = 1.0, density: float = 1.0, + groove_strength: float = 0.0, ) -> dict[int, list[dict]]: """Dispatch to the named generator. Raises KeyError if not found.""" gen = GENERATORS[generator_name] - return gen(bars, velocity_mult, density) + return gen(bars, velocity_mult, density, groove_strength) diff --git a/src/composer/variation.py b/src/composer/variation.py index 3d74b19..ebe4ab8 100644 --- a/src/composer/variation.py +++ b/src/composer/variation.py @@ -18,7 +18,7 @@ import random from pathlib import Path from typing import Iterator -from ..flp_builder.schema import ( +from ..core.schema import ( ArrangementItemDef, ArrangementTrack, PatternDef, diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/schema.py b/src/core/schema.py new file mode 100644 index 0000000..2721673 --- /dev/null +++ b/src/core/schema.py @@ -0,0 +1,253 @@ +"""Core schema definitions for REAPER project generation. + +Represents the intermediate representation (SongDefinition) used to build +REAPER .rpp files via RPPBuilder. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Key validation +# --------------------------------------------------------------------------- +import re + +_KEY_RE = re.compile(r"^[A-G][b#]?m?$") + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class SongMeta: + """Song metadata — tempo, key, time signature.""" + + bpm: float # 20–999 + key: str # e.g. "Am", "Dm", "Gm" + title: str = "" # song title + ppq: int = 960 # ticks per quarter note (REAPER default) + time_sig_num: int = 4 # numerator e.g. 4 + time_sig_den: int = 4 # denominator e.g. 4 + + +@dataclass +class MidiNote: + """A single MIDI note event. + + Attributes: + pitch: MIDI note number 0–127 (60 = middle C) + start: Start time in beats (from start of item) + duration: Duration in beats + velocity: 0–127 + """ + + pitch: int + start: float # beats + duration: float # beats + velocity: int = 64 + + +@dataclass +class ArrangementTrack: + """A track in the REAPER arrangement with index and display name.""" + + index: int + name: str + + +@dataclass +class ArrangementItemDef: + """An item placed in the arrangement referencing a pattern on a track. + + Attributes: + pattern: Pattern ID + bar: Start position in bars (float) + bars: Length in bars (float) + track: Track index + """ + + pattern: int + bar: float + bars: float + track: int + + +@dataclass +class PatternDef: + """A pattern definition with generator and variation axes. + + Attributes: + id: Unique pattern ID + name: Display name (e.g. "Kick Main") + instrument: Sample/instrument key (e.g. "kick", "snare") + channel: MIDI channel (11 = kick, 12 = snare, etc.) + bars: Length in bars + generator: Generator function name + velocity_mult: Velocity multiplier (0.85–1.1) + density: Note density 0.0–1.0 + """ + + id: int + name: str + instrument: str + channel: int + bars: int + generator: str + velocity_mult: float = 1.0 + density: float = 1.0 + + +@dataclass +class ClipDef: + """A clip placed on a track — either audio or MIDI. + + Attributes: + position: Start position in beats + length: Duration in beats + audio_path: Absolute path to audio file (for audio clips) + midi_notes: List of MIDI notes (for MIDI clips) + name: Display name + """ + + position: float + length: float + name: str = "" + audio_path: str | None = None # for audio clips + midi_notes: list[MidiNote] = field(default_factory=list) # for MIDI clips + + @property + def is_midi(self) -> bool: + return bool(self.midi_notes) + + @property + def is_audio(self) -> bool: + return self.audio_path is not None + + +@dataclass +class PluginDef: + """A VST plugin instance on a track. + + Attributes: + name: Display name (e.g. "Serum 2") + path: Plugin path/identifier (e.g. "VST3: Serum 2 (Xfer Records)") + index: Chain position (0 = first) + params: Optional dict of parameter index → value + """ + + name: str + path: str + index: int = 0 + params: dict[int, float] = field(default_factory=dict) + + +@dataclass +class TrackDef: + """A track in the REAPER project. + + Attributes: + name: Track display name + volume: 0.0–1.0 (maps to REAPER volume fader) + pan: -1.0 to 1.0 + color: REAPER color index (0–67), 0 = default + clips: Audio/MIDI clips placed on this track + plugins: VST plugins on this track + send_reverb: Reverb send level 0.0–1.0 + send_delay: Delay send level 0.0–1.0 + """ + + name: str + volume: float = 0.85 + pan: float = 0.0 + color: int = 0 + clips: list[ClipDef] = field(default_factory=list) + plugins: list[PluginDef] = field(default_factory=list) + send_reverb: float = 0.0 + send_delay: float = 0.0 + + +@dataclass +class SongDefinition: + """Complete song definition — the source of truth for one .rpp file. + + This holds the minimal data needed by RPPBuilder to write a complete .rpp. + + Attributes: + meta: Song metadata (bpm, key, title, time signature) + tracks: List of REAPER tracks (TrackDef) with clips and plugins + patterns: Pattern definitions (PatternDef) for arrangement + items: Arrangement items (ArrangementItemDef) referencing patterns + progression_name: Chord progression name (e.g. "i-VII-VI-VII") + section_template: Section template name (default "standard") + samples: Sample file map (name → filename) + """ + + meta: SongMeta + tracks: list[TrackDef] = field(default_factory=list) + patterns: list[PatternDef] = field(default_factory=list) + items: list[ArrangementItemDef] = field(default_factory=list) + progression_name: str = "i-VII-VI-VII" + section_template: str = "standard" + samples: dict[str, str] = field(default_factory=dict) + + # ------------------------------------------------------------------------- + # Validation + # ------------------------------------------------------------------------- + + def validate(self) -> list[str]: + """Return list of validation errors (empty list = valid).""" + errors: list[str] = [] + + # BPM range + if not (20 <= self.meta.bpm <= 999): + errors.append(f"meta.bpm must be 20–999, got {self.meta.bpm}") + + # Key format + if not _KEY_RE.match(self.meta.key): + errors.append(f"meta.key must match ^[A-G][b#]?m?$, got '{self.meta.key}'") + + # Track names unique + names = [t.name for t in self.tracks] + if len(names) != len(set(names)): + errors.append("Duplicate track names found") + + # Check for clips with neither audio_path nor midi_notes + for ti, track in enumerate(self.tracks): + for ci, clip in enumerate(track.clips): + if not clip.is_audio and not clip.is_midi: + errors.append( + f"tracks[{ti}].clips[{ci}] has no audio_path and no midi_notes" + ) + + return errors + + # ------------------------------------------------------------------------- + # Computed helpers + # ------------------------------------------------------------------------- + + @property + def length_beats(self) -> float: + """Compute the total length in beats from all clips.""" + if not self.tracks: + return 0.0 + max_end = 0.0 + for track in self.tracks: + for clip in track.clips: + end = clip.position + clip.length + if end > max_end: + max_end = end + return max_end + + # ------------------------------------------------------------------------- + # Serialization + # ------------------------------------------------------------------------- + + def to_json(self, indent: int = 2) -> str: + """Serialize to a JSON string.""" + import json + from dataclasses import asdict + return json.dumps(asdict(self), indent=indent, ensure_ascii=False) \ No newline at end of file diff --git a/src/flp_builder/__init__.py b/src/flp_builder/__init__.py deleted file mode 100644 index 608ff9c..0000000 --- a/src/flp_builder/__init__.py +++ /dev/null @@ -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", -] diff --git a/src/flp_builder/arrangement.py b/src/flp_builder/arrangement.py deleted file mode 100644 index b73eb89..0000000 --- a/src/flp_builder/arrangement.py +++ /dev/null @@ -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( - " 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(" 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) diff --git a/src/flp_builder/builder.py b/src/flp_builder/builder.py deleted file mode 100644 index d73cd69..0000000 --- a/src/flp_builder/builder.py +++ /dev/null @@ -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(" 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(" 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(" 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 diff --git a/src/flp_builder/events.py b/src/flp_builder/events.py deleted file mode 100644 index b5932d7..0000000 --- a/src/flp_builder/events.py +++ /dev/null @@ -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(" bytes: - return bytes([id_]) + struct.pack(" 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( - " 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) diff --git a/src/flp_builder/project.py b/src/flp_builder/project.py deleted file mode 100644 index 4e5192e..0000000 --- a/src/flp_builder/project.py +++ /dev/null @@ -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 diff --git a/src/flp_builder/schema.py b/src/flp_builder/schema.py deleted file mode 100644 index d7c6aca..0000000 --- a/src/flp_builder/schema.py +++ /dev/null @@ -1,395 +0,0 @@ -"""Song definition schema for FL Studio FLP generation. - -Provides the JSON contract that decouples song composition from FLP rendering. -A SongDefinition is the single source of truth for one ``.flp`` file. - -Usage:: - - song = SongDefinition.load_file("knowledge/songs/reggaeton_template.json") - errors = song.validate() - json_str = song.to_json() -""" - -from __future__ import annotations - -import json -import re -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Any - - -# --------------------------------------------------------------------------- -# Key validation pattern: A-G, optional flat/sharp, optional minor 'm' -# --------------------------------------------------------------------------- -_KEY_RE = re.compile(r"^[A-G][b#]?m?$") - -# Allowed top-level keys in the JSON document -_TOP_LEVEL_KEYS = frozenset({ - "meta", "samples", "patterns", "tracks", "items", - "melodic_tracks", "progression_name", "section_template", -}) - -# Allowed keys in nested objects -_META_KEYS = frozenset({ - "bpm", "key", "title", "ppq", "time_sig_num", "time_sig_den", -}) -_PATTERN_KEYS = frozenset({ - "id", "name", "instrument", "channel", "bars", "generator", - "velocity_mult", "density", -}) -_TRACK_KEYS = frozenset({"index", "name"}) -_ITEM_KEYS = frozenset({"pattern", "bar", "bars", "track", "muted"}) - - -# --------------------------------------------------------------------------- -# Dataclasses -# --------------------------------------------------------------------------- - -@dataclass -class SongMeta: - """Song metadata — tempo, key, time signature.""" - - bpm: float # 20–999 - key: str # e.g. "Am", "Dm", "Gm" - title: str # song title - ppq: int = 96 # ticks per quarter note - time_sig_num: int = 4 - time_sig_den: int = 4 - - -@dataclass -class PatternNote: - """A single note within a pattern (used when embedding notes directly).""" - - pos: float # beat position (0.0 = beat 1 of bar) - len: float # duration in beats - key: int # MIDI note (60 = C4) - vel: int # velocity 0–127 - - -@dataclass -class PatternDef: - """Pattern definition — recipe for generating note data. - - The ``generator`` field names a function in ``composer/rhythm.py`` - that produces the actual MIDI notes for this pattern. - """ - - id: int # pattern number (1-based) - name: str # human label - instrument: str # "kick", "snare", "hihat", etc. - channel: int # channel rack index (10–16) - bars: int # pattern length in bars - generator: str # rhythm.py function name - velocity_mult: float = 1.0 # scales all velocities - density: float = 1.0 # 0.5=sparse, 1.0=full - - -@dataclass -class ArrangementTrack: - """A track row in the FL Studio playlist / arrangement.""" - - index: int # 1-based track index in arrangement - name: str # display name - - -@dataclass -class ArrangementItemDef: - """Placement of a pattern on the arrangement timeline.""" - - pattern: int # pattern id - bar: float # start bar (0-based) - bars: float # duration in bars - track: int # track index (1-based, must exist in tracks[]) - muted: bool = False - - -@dataclass -class MelodicNote: - """A single note in a melodic track. Unified format: {pos, len, key, vel}.""" - - pos: float # beat position (0.0 = beat 1 of bar) - len: float # duration in beats - key: int # MIDI note (60 = C4) - vel: int # velocity 0–127 - - -@dataclass -class MelodicTrack: - """A melodic track referencing an audio sample with MIDI note triggers. - - The sample is loaded into a sampler channel and notes trigger playback. - """ - - role: str # "bass", "lead", "pad", "pluck", etc. - sample_path: str # absolute path to .wav file - notes: list[MelodicNote] # note events - channel_index: int # FL Studio channel (17+ for melodic) - volume: float = 0.85 # 0.0–1.0 - pan: float = 0.0 # -1.0 to 1.0 - - -@dataclass -class SongDefinition: - """Complete song definition — the single source of truth for one .flp. - - Serialization round-trips through ``to_json()`` / ``from_json()``. - Use ``validate()`` to check constraints before rendering. - """ - - meta: SongMeta - samples: dict[str, str] # {"kick": "kick.wav", ...} - patterns: list[PatternDef] - tracks: list[ArrangementTrack] - items: list[ArrangementItemDef] - melodic_tracks: list[MelodicTrack] = field(default_factory=list) - - # Optional metadata for variation engine - progression_name: str = "" - section_template: str = "standard" - - # ------------------------------------------------------------------ - # Validation - # ------------------------------------------------------------------ - - def validate(self) -> list[str]: - """Return list of validation errors (empty list = valid). - - Checks: - 1. meta.bpm in 20–999 - 2. meta.key matches ``^[A-G][b#]?m?$`` - 3. meta.ppq == 96 - 4. All pattern ``id`` values are unique - 5. All ``item.pattern`` reference an existing pattern id - 6. All ``item.track`` reference an existing track index - """ - errors: list[str] = [] - - # 1. BPM range - if not (20 <= self.meta.bpm <= 999): - errors.append( - f"meta.bpm must be 20–999, got {self.meta.bpm}" - ) - - # 2. Key format - if not _KEY_RE.match(self.meta.key): - errors.append( - f"meta.key must match ^[A-G][b#]?m?$, got '{self.meta.key}'" - ) - - # 3. PPQ - if self.meta.ppq != 96: - errors.append( - f"meta.ppq must be 96, got {self.meta.ppq}" - ) - - # 4. Unique pattern ids - pattern_ids = [p.id for p in self.patterns] - seen: set[int] = set() - for pid in pattern_ids: - if pid in seen: - errors.append(f"Duplicate pattern id: {pid}") - seen.add(pid) - - valid_pattern_ids = set(pattern_ids) - - # 5. All items reference valid pattern id - for i, item in enumerate(self.items): - if item.pattern not in valid_pattern_ids: - errors.append( - f"items[{i}].pattern={item.pattern} does not reference " - f"an existing pattern id" - ) - - # 6. All items reference valid track index - valid_track_indices = {t.index for t in self.tracks} - for i, item in enumerate(self.items): - if item.track not in valid_track_indices: - errors.append( - f"items[{i}].track={item.track} does not reference " - f"an existing track index" - ) - - return errors - - # ------------------------------------------------------------------ - # Serialization - # ------------------------------------------------------------------ - - def to_json(self, indent: int = 2) -> str: - """Serialize to a JSON string.""" - return json.dumps(asdict(self), indent=indent, ensure_ascii=False) - - @classmethod - def from_json(cls, data: str | dict) -> SongDefinition: - """Deserialize from a JSON string or dict. - - Raises: - ValueError: On unknown keys, missing fields, or validation errors. - """ - if isinstance(data, str): - raw = json.loads(data) - else: - raw = data - - if not isinstance(raw, dict): - raise ValueError(f"Expected dict, got {type(raw).__name__}") - - # Reject unknown top-level keys - unknown = set(raw.keys()) - _TOP_LEVEL_KEYS - if unknown: - raise ValueError(f"Unknown top-level keys: {sorted(unknown)}") - - # --- meta --- - meta_raw = raw.get("meta") - if not isinstance(meta_raw, dict): - raise ValueError("Missing or invalid 'meta' object") - - unknown_meta = set(meta_raw.keys()) - _META_KEYS - if unknown_meta: - raise ValueError(f"Unknown meta keys: {sorted(unknown_meta)}") - - try: - meta = SongMeta( - bpm=float(meta_raw["bpm"]), - key=str(meta_raw["key"]), - title=str(meta_raw.get("title", "")), - ppq=int(meta_raw.get("ppq", 96)), - time_sig_num=int(meta_raw.get("time_sig_num", 4)), - time_sig_den=int(meta_raw.get("time_sig_den", 4)), - ) - except KeyError as exc: - raise ValueError(f"Missing required meta field: {exc}") from exc - - # --- samples --- - samples = raw.get("samples") - if not isinstance(samples, dict): - raise ValueError("Missing or invalid 'samples' dict") - - # --- patterns --- - patterns_raw = raw.get("patterns") - if not isinstance(patterns_raw, list): - raise ValueError("Missing or invalid 'patterns' list") - - patterns: list[PatternDef] = [] - for idx, p in enumerate(patterns_raw): - if not isinstance(p, dict): - raise ValueError(f"patterns[{idx}] must be a dict") - unknown_p = set(p.keys()) - _PATTERN_KEYS - if unknown_p: - raise ValueError( - f"patterns[{idx}] unknown keys: {sorted(unknown_p)}" - ) - try: - patterns.append(PatternDef( - id=int(p["id"]), - name=str(p["name"]), - instrument=str(p["instrument"]), - channel=int(p["channel"]), - bars=int(p["bars"]), - generator=str(p["generator"]), - velocity_mult=float(p.get("velocity_mult", 1.0)), - density=float(p.get("density", 1.0)), - )) - except KeyError as exc: - raise ValueError( - f"patterns[{idx}] missing required field: {exc}" - ) from exc - - # --- tracks --- - tracks_raw = raw.get("tracks") - if not isinstance(tracks_raw, list): - raise ValueError("Missing or invalid 'tracks' list") - - tracks: list[ArrangementTrack] = [] - for idx, t in enumerate(tracks_raw): - if not isinstance(t, dict): - raise ValueError(f"tracks[{idx}] must be a dict") - unknown_t = set(t.keys()) - _TRACK_KEYS - if unknown_t: - raise ValueError( - f"tracks[{idx}] unknown keys: {sorted(unknown_t)}" - ) - try: - tracks.append(ArrangementTrack( - index=int(t["index"]), - name=str(t["name"]), - )) - except KeyError as exc: - raise ValueError( - f"tracks[{idx}] missing required field: {exc}" - ) from exc - - # --- items --- - items_raw = raw.get("items") - if not isinstance(items_raw, list): - raise ValueError("Missing or invalid 'items' list") - - items: list[ArrangementItemDef] = [] - for idx, it in enumerate(items_raw): - if not isinstance(it, dict): - raise ValueError(f"items[{idx}] must be a dict") - unknown_it = set(it.keys()) - _ITEM_KEYS - if unknown_it: - raise ValueError( - f"items[{idx}] unknown keys: {sorted(unknown_it)}" - ) - try: - items.append(ArrangementItemDef( - pattern=int(it["pattern"]), - bar=float(it["bar"]), - bars=float(it["bars"]), - track=int(it["track"]), - muted=bool(it.get("muted", False)), - )) - except KeyError as exc: - raise ValueError( - f"items[{idx}] missing required field: {exc}" - ) from exc - - song = cls( - meta=meta, - samples=samples, - patterns=patterns, - tracks=tracks, - items=items, - progression_name=str(raw.get("progression_name", "")), - section_template=str(raw.get("section_template", "standard")), - ) - - # Validate and raise on errors - errors = song.validate() - if errors: - raise ValueError( - "Song validation failed:\n - " + "\n - ".join(errors) - ) - - return song - - @classmethod - def load_file(cls, path: str | Path) -> SongDefinition: - """Load and validate from a ``.json`` file. - - Raises: - FileNotFoundError: If the file does not exist. - ValueError: If validation fails. - """ - p = Path(path) - if not p.exists(): - raise FileNotFoundError(f"Song file not found: {p}") - return cls.from_json(p.read_text(encoding="utf-8")) - - -# --------------------------------------------------------------------------- -# Convenience -# --------------------------------------------------------------------------- - -def load_song_json(path: str | Path) -> SongDefinition: - """Load + validate a song definition from a JSON file. - - Raises: - ValueError: If validation fails. - FileNotFoundError: If file does not exist. - """ - return SongDefinition.load_file(path) diff --git a/src/flp_builder/skeleton.py b/src/flp_builder/skeleton.py deleted file mode 100644 index 4efb2aa..0000000 --- a/src/flp_builder/skeleton.py +++ /dev/null @@ -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 ".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(" 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(" 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(" 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(" 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) diff --git a/src/flp_builder/writer.py b/src/flp_builder/writer.py deleted file mode 100644 index 65a96d5..0000000 --- a/src/flp_builder/writer.py +++ /dev/null @@ -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(" bytes: - # Minimal native plugin state - stub = struct.pack(" 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(" 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 \ No newline at end of file diff --git a/src/reaper_builder/render.py b/src/reaper_builder/render.py new file mode 100644 index 0000000..29578e0 --- /dev/null +++ b/src/reaper_builder/render.py @@ -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}" + ) \ No newline at end of file diff --git a/src/scanner/__init__.py b/src/scanner/__init__.py deleted file mode 100644 index fbe8cd4..0000000 --- a/src/scanner/__init__.py +++ /dev/null @@ -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)) diff --git a/tests/test_compose_integration.py b/tests/test_compose_integration.py new file mode 100644 index 0000000..5d9eeb1 --- /dev/null +++ b/tests/test_compose_integration.py @@ -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 = 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 diff --git a/tests/test_converters.py b/tests/test_converters.py new file mode 100644 index 0000000..e960d94 --- /dev/null +++ b/tests/test_converters.py @@ -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 == [] diff --git a/tests/test_core_schema.py b/tests/test_core_schema.py new file mode 100644 index 0000000..e6d771d --- /dev/null +++ b/tests/test_core_schema.py @@ -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 diff --git a/tests/test_reaper_builder.py b/tests/test_reaper_builder.py new file mode 100644 index 0000000..f9de456 --- /dev/null +++ b/tests/test_reaper_builder.py @@ -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 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 "\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('\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) diff --git a/tests/test_render_cli.py b/tests/test_render_cli.py new file mode 100644 index 0000000..11bb414 --- /dev/null +++ b/tests/test_render_cli.py @@ -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() \ No newline at end of file