feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
222
src/flp_builder/arrangement.py
Normal file
222
src/flp_builder/arrangement.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""FL Studio arrangement/playlist encoding.
|
||||
|
||||
Encodes playlist items (ID233) and track data (ID238) into binary format
|
||||
matching FL Studio's internal structure. Extracted from the proven v15 builder
|
||||
(output/build_reggaeton_v15.py, lines 61-90).
|
||||
|
||||
Arrangement block sequence:
|
||||
ArrNew(99) → ArrName(241) → Flag36(36) → Playlist(233)
|
||||
→ TrackData(238)×N → ArrCurrent(100)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
|
||||
from .events import encode_byte_event, encode_data_event, encode_word_event
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PPQ_DEFAULT: int = 96
|
||||
MAX_TRACKS_DEFAULT: int = 500
|
||||
PATTERN_BASE: int = 20480
|
||||
|
||||
# Arrangement event IDs (not yet in EventID enum — raw constants)
|
||||
EID_ARR_NEW = 99
|
||||
EID_ARR_CURRENT = 100
|
||||
EID_ARR_NAME = 241
|
||||
EID_FLAG_36 = 36
|
||||
EID_PLAYLIST = 233
|
||||
EID_TRACK_DATA = 238
|
||||
|
||||
# TrackData template size (bytes), extracted from reference FLP
|
||||
TRACK_DATA_SIZE = 66
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ArrangementItem dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ArrangementItem:
|
||||
"""A single playlist item placed on the arrangement timeline.
|
||||
|
||||
Args:
|
||||
pattern_id: Pattern number (1-based).
|
||||
bar: Start bar (0-based, fractional allowed).
|
||||
num_bars: Length in bars (fractional allowed).
|
||||
track_index: Track row index (0-based).
|
||||
muted: Whether the item is muted in the playlist.
|
||||
"""
|
||||
|
||||
pattern_id: int # pattern number (1-based)
|
||||
bar: float # start bar (0-based)
|
||||
num_bars: float # length in bars
|
||||
track_index: int # 0-based track index
|
||||
muted: bool = False
|
||||
|
||||
def to_bytes(
|
||||
self,
|
||||
ppq: int = PPQ_DEFAULT,
|
||||
max_tracks: int = MAX_TRACKS_DEFAULT,
|
||||
) -> bytes:
|
||||
"""Encode as a 32-byte playlist item (ID233 format).
|
||||
|
||||
Encoding rules (from reverse-engineered FL Studio format):
|
||||
position = int(bar × ppq × 4) — ticks, truncated
|
||||
pattern_base = 20480 — constant
|
||||
item_index = 20480 + pattern_id
|
||||
length = int(num_bars × ppq × 4) — ticks, truncated
|
||||
track_rvidx = (max_tracks - 1) - track_index — REVERSED
|
||||
flags = 0x2040 if muted else 0x0040
|
||||
"""
|
||||
position = int(self.bar * ppq * 4)
|
||||
item_index = PATTERN_BASE + self.pattern_id
|
||||
length = int(self.num_bars * ppq * 4)
|
||||
track_rvidx = (max_tracks - 1) - self.track_index
|
||||
flags = 0x2040 if self.muted else 0x0040
|
||||
|
||||
return struct.pack(
|
||||
"<IHHIHH HH 4B ff",
|
||||
position,
|
||||
PATTERN_BASE,
|
||||
item_index,
|
||||
length,
|
||||
track_rvidx,
|
||||
0, # group
|
||||
0x0078,
|
||||
flags,
|
||||
64, 100, 128, 128,
|
||||
-1.0, -1.0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TrackData helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_track_data_template(reference_flp_bytes: bytes) -> bytes:
|
||||
"""Extract the 66-byte TrackData template from a reference FLP.
|
||||
|
||||
Scans the raw FLP bytes for the first ID238 event and returns its
|
||||
66-byte payload. This template is then cloned and patched for each
|
||||
of the *max_tracks* track data entries in the arrangement section.
|
||||
|
||||
Args:
|
||||
reference_flp_bytes: Full contents of a valid .flp file.
|
||||
|
||||
Returns:
|
||||
The 66-byte track-data template.
|
||||
|
||||
Raises:
|
||||
ValueError: If no ID238 event of the expected size is found.
|
||||
"""
|
||||
pos = 22 # skip FLhd (14 bytes) + FLdt header (8 bytes)
|
||||
|
||||
while pos < len(reference_flp_bytes):
|
||||
ib = reference_flp_bytes[pos]
|
||||
pos += 1
|
||||
|
||||
if ib < 64:
|
||||
# Byte event: 1-byte value
|
||||
pos += 1
|
||||
elif ib < 128:
|
||||
# Word event: 2-byte value
|
||||
pos += 2
|
||||
elif ib < 192:
|
||||
# Dword event: 4-byte value
|
||||
pos += 4
|
||||
else:
|
||||
# Data / text event: varint length + payload
|
||||
size = 0
|
||||
shift = 0
|
||||
while True:
|
||||
b = reference_flp_bytes[pos]
|
||||
pos += 1
|
||||
size |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not (b & 0x80):
|
||||
break
|
||||
|
||||
if ib == EID_TRACK_DATA and size == TRACK_DATA_SIZE:
|
||||
return bytes(reference_flp_bytes[pos:pos + size])
|
||||
|
||||
pos += size
|
||||
|
||||
raise ValueError(
|
||||
f"No ID{EID_TRACK_DATA} TrackData event ({TRACK_DATA_SIZE} bytes) "
|
||||
"found in reference FLP"
|
||||
)
|
||||
|
||||
|
||||
def encode_track_data(iid: int, enabled: int, template: bytes) -> bytes:
|
||||
"""Clone *template*, patch iid at byte 0 (uint32 LE) and enabled at byte 12.
|
||||
|
||||
Args:
|
||||
iid: Internal track ID (sequential from 1).
|
||||
enabled: 0 = disabled, 1 = enabled.
|
||||
template: 66-byte template extracted by :func:`build_track_data_template`.
|
||||
|
||||
Returns:
|
||||
66-byte patched track data.
|
||||
"""
|
||||
td = bytearray(template)
|
||||
struct.pack_into("<I", td, 0, iid)
|
||||
td[12] = enabled & 0xFF
|
||||
return bytes(td)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full arrangement section builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_arrangement_section(
|
||||
items: list[ArrangementItem],
|
||||
track_data_template: bytes,
|
||||
ppq: int = PPQ_DEFAULT,
|
||||
max_tracks: int = MAX_TRACKS_DEFAULT,
|
||||
) -> bytes:
|
||||
"""Build the full post-channel arrangement section bytes.
|
||||
|
||||
Produces the exact byte sequence FL Studio expects after the channel
|
||||
events:
|
||||
|
||||
ArrNew(99) → ArrName(241) → Flag36(36) → Playlist(233)
|
||||
→ TrackData(238) × *max_tracks* → ArrCurrent(100)
|
||||
|
||||
Args:
|
||||
items: Playlist items to encode.
|
||||
track_data_template: 66-byte template from :func:`build_track_data_template`.
|
||||
ppq: Pulses-per-quarter-note (default 96).
|
||||
max_tracks: Total track-data entries to write (default 500).
|
||||
|
||||
Returns:
|
||||
Complete arrangement section as raw bytes.
|
||||
"""
|
||||
result = bytearray()
|
||||
|
||||
# 1. ArrNew — word event, value = 0
|
||||
result.extend(encode_word_event(EID_ARR_NEW, 0))
|
||||
|
||||
# 2. ArrName — "Arrangement" as UTF-16-LE + null terminator
|
||||
arr_name = "Arrangement".encode("utf-16-le") + b"\x00\x00"
|
||||
result.extend(encode_data_event(EID_ARR_NAME, arr_name))
|
||||
|
||||
# 3. Flag36 — byte event, value = 0
|
||||
result.extend(encode_byte_event(EID_FLAG_36, 0))
|
||||
|
||||
# 4. Playlist — data event, concatenation of all 32-byte items
|
||||
pl_data = b"".join(item.to_bytes(ppq, max_tracks) for item in items)
|
||||
result.extend(encode_data_event(EID_PLAYLIST, pl_data))
|
||||
|
||||
# 5. TrackData × max_tracks — first track (iid=1) disabled, rest enabled
|
||||
for i in range(1, max_tracks + 1):
|
||||
enabled = 0 if i == 1 else 1
|
||||
td = encode_track_data(i, enabled, track_data_template)
|
||||
result.extend(encode_data_event(EID_TRACK_DATA, td))
|
||||
|
||||
# 6. ArrCurrent — word event, value = 0
|
||||
result.extend(encode_word_event(EID_ARR_CURRENT, 0))
|
||||
|
||||
return bytes(result)
|
||||
Reference in New Issue
Block a user