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