223 lines
7.3 KiB
Python
223 lines
7.3 KiB
Python
"""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)
|