Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Parse FL Studio 2024/2025 (21.2+ / 25.x) playlist items, stored as 80-byte records
(a 20-byte tail after FL 21's 28-byte tail). Previously these events failed the size
check and their clips were dropped (#177, #199, #200). Detection prefers the 60-byte
reading on a 60/80 common multiple, so existing files are unaffected.

## [2.2.1] - 2023-06-05

### Fixed
Expand Down
27 changes: 25 additions & 2 deletions pyflp/arrangement.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ class PLSelectionEvent(StructEventBase):


class PlaylistEvent(ListEventBase):
# Each playlist item is a fixed-size record whose length grew across FL versions:
# * 32 bytes - up to FL 20.
# * 60 bytes - FL 21 added a 28-byte tail (``_u3``).
# * 80 bytes - FL 2024/2025 (21.2+ / 25.x) added a further 20-byte tail (``_u4``).
# Verified byte-for-byte against FL Studio 2025 saves; see #177, #199, #200.
#
# ``item_index`` selects the item type: ``< pattern_base`` (20480) references a channel
# by its iid - an audio clip if that channel's ``ChannelID.Type`` is 4, an automation
# clip if it is 5 - while ``>= pattern_base`` is a pattern clip (pattern number =
# ``item_index - pattern_base``). ``length`` is in PPQ ticks.
#
# NOTE: a *freshly placed* clip is exactly one of the sizes above, but clips edited in
# FL (fades, slices, resizes) carry extra per-clip data and grow beyond it, so a real
# project's Playlist event can be variable-length and match none of ``SIZES``. Payload
# lengths that are a common multiple (e.g. 240 = 3*80 = 4*60) are ambiguous without the
# project's FL version; the heuristic below prefers the FL 21 (60-byte) reading for
# backwards compatibility, so this change never regresses files that parsed before.
STRUCT = c.GreedyRange(
c.Struct(
"position" / c.Int32ul, # 4
Expand All @@ -82,12 +99,18 @@ class PlaylistEvent(ListEventBase):
"start_offset" / c.Float32l, # 28
"end_offset" / c.Float32l, # 32
"_u3" / c.If(c.this._params["new"], c.Bytes(28)) * "New in FL 21", # 60
"_u4" / c.If(c.this._params["fl2025"], c.Bytes(20)) * "New in FL 2024/2025", # 80
)
)
SIZES = [32, 60]
# Ordered so the fixed size that divides the payload matches the parsed record size:
# 60 wins a 60/80 common multiple (back-compat), 80 before 32 for FL 2024/2025 events.
SIZES = [60, 80, 32]

def __init__(self, id: EventEnum, data: bytes) -> None:
super().__init__(id, data, new=not len(data) % 60)
# 80-byte records are FL 2024/2025; 60-byte are FL 21; 32-byte are older. Detect by
# the fixed size that divides the payload, preferring 60 on a 60/80 common multiple.
fl2025 = len(data) % 80 == 0 and len(data) % 60 != 0
super().__init__(id, data, new=fl2025 or not len(data) % 60, fl2025=fl2025)


@enum.unique
Expand Down
35 changes: 35 additions & 0 deletions tests/test_arrangement.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,38 @@ def test_second_arrangement(arrangement: Callable[[int], Arrangement]):
assert arr.name == "Just timemarkers"
assert len(tuple(arr.timemarkers)) == 11
assert len(tuple(arr.tracks)) == 500


def test_fl2025_80byte_playlist_records_parse():
"""FL 2024/2025 (21.2+ / 25.x) stores each playlist item as an 80-byte record.

Before this, ``PlaylistEvent`` only knew the 32- and 60-byte layouts, so FL 2024/2025
playlist events failed the size check and their clips were dropped (see #177, #199, #200).
Build a synthetic two-clip FL 2025 event and assert it parses and round-trips.
"""
import struct

from pyflp.arrangement import ArrangementID, PlaylistEvent

def rec(pos: int, iid: int, length: int, track: int, uid: int) -> bytes:
core = struct.pack(
"<IHHIHH2sH4sff", pos, 20480, iid, length, 499 - track, 0,
b"\x78\x00", 0x40, b"\x40\x64\x80\x80", -1.0, -1.0,
)
trailer = (
struct.pack("<I", uid) + b"\x00" * 16 + struct.pack("<f", 1.0)
+ b"\x00" * 8 + struct.pack("<d", 1.0) + b"\x00" * 8
)
return core + trailer # 32 + 48 = 80 bytes

data = rec(0, 0, 40119, 1, 0x10) + rec(1536, 1, 29000, 2, 0x11)
assert len(data) == 160

event = PlaylistEvent(ArrangementID.Playlist, data)
items = list(event.value)
assert len(items) == 2
assert [i["item_index"] for i in items] == [0, 1]
assert [i["position"] for i in items] == [0, 1536]
assert [i["length"] for i in items] == [40119, 29000]
# byte-for-byte round-trip
assert PlaylistEvent.STRUCT.build(event.value, **event._kwds) == data