flpkit 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flpkit/__init__.py +512 -0
- flpkit/codec.py +317 -0
- flpkit/data/plugins/fruity-parametric-eq-2.json +8 -0
- flpkit/data/plugins/index.json +3 -0
- flpkit/detect.py +83 -0
- flpkit/formats/__init__.py +17 -0
- flpkit/formats/automation.py +189 -0
- flpkit/formats/effects.py +170 -0
- flpkit/formats/levels.py +97 -0
- flpkit/formats/notes.py +219 -0
- flpkit/formats/playlist.py +173 -0
- flpkit/formats/tempo.py +50 -0
- flpkit/py.typed +0 -0
- flpkit-0.8.1.dist-info/METADATA +124 -0
- flpkit-0.8.1.dist-info/RECORD +17 -0
- flpkit-0.8.1.dist-info/WHEEL +4 -0
- flpkit-0.8.1.dist-info/licenses/LICENSE +21 -0
flpkit/codec.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""The ONE read+write engine over the FLP event stream.
|
|
2
|
+
|
|
3
|
+
A .flp element type (notes, playlist, automation, levels, tempo, effects) implements
|
|
4
|
+
the ``Format`` protocol; :func:`read` and :func:`patch` are the only paths
|
|
5
|
+
that touch the file. All the messy splice and chunk-length math lives here,
|
|
6
|
+
once. Writes are raw byte surgery - patch exactly the bytes that express the
|
|
7
|
+
change, never reserialize the whole file (FL rejects whole-file
|
|
8
|
+
reserialization by third-party writers; verified live 2026-08-27) - and every
|
|
9
|
+
write verifies itself by re-reading the saved file.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Literal, Protocol
|
|
18
|
+
|
|
19
|
+
from .detect import resolve_size_overrides
|
|
20
|
+
|
|
21
|
+
Mode = Literal["merge", "replace"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FlpError(ValueError):
|
|
25
|
+
"""Malformed .flp input or a write whose readback did not match."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# -- stream structure facts (verified live; see the parent project's e2e suite)
|
|
29
|
+
EVENT_CHANNEL_NEW = 64 # u16 channel IID; starts that channel's event block
|
|
30
|
+
EVENT_MIXER_FLAGS = 236 # InsertID.Flags; first one means the mixer section began
|
|
31
|
+
EVENT_CHANNEL_TYPE = 21 # 1 byte; 5 marks an automation channel
|
|
32
|
+
EVENT_CHANNEL_LEVELS = 219 # 24B: pan i32, volume u32, pitch i32, filter 12B
|
|
33
|
+
EVENT_CHANNEL_AUTOMATION = 234 # the channel's automation points blob
|
|
34
|
+
EVENT_NAME_LEGACY = 192 # ChannelID._Name
|
|
35
|
+
EVENT_NAME_INTERNAL = 201 # PluginID.InternalName (e.g. the generator name)
|
|
36
|
+
EVENT_NAME_USER = 203 # PluginID.Name (the user-visible rename)
|
|
37
|
+
EVENT_VOL_WORD = 72 # legacy per-channel volume (pre-Levels files)
|
|
38
|
+
EVENT_PAN_WORD = 73 # legacy per-channel pan
|
|
39
|
+
EVENT_VOL_BYTE = 2 # older still
|
|
40
|
+
EVENT_PAN_BYTE = 3
|
|
41
|
+
|
|
42
|
+
# Events that belong to the currently open rack channel.
|
|
43
|
+
CHANNEL_SCOPED = frozenset((
|
|
44
|
+
EVENT_CHANNEL_TYPE, EVENT_CHANNEL_LEVELS, EVENT_CHANNEL_AUTOMATION,
|
|
45
|
+
EVENT_NAME_LEGACY, EVENT_NAME_INTERNAL, EVENT_NAME_USER,
|
|
46
|
+
EVENT_VOL_WORD, EVENT_PAN_WORD, EVENT_VOL_BYTE, EVENT_PAN_BYTE,
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_channel_scoped(event_id: int) -> bool:
|
|
51
|
+
return event_id in CHANNEL_SCOPED
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Target:
|
|
56
|
+
"""Which element instance a Format addresses; unused fields stay None."""
|
|
57
|
+
|
|
58
|
+
pattern: int | None = None
|
|
59
|
+
channel: int | None = None
|
|
60
|
+
arrangement: int | None = None
|
|
61
|
+
insert: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class SpliceSite:
|
|
66
|
+
"""Everything one locating walk found about WHERE an element lives.
|
|
67
|
+
|
|
68
|
+
``scoped``/``rest`` split the payload by the target: :func:`read` decodes
|
|
69
|
+
``scoped``; a replace write keeps ``rest`` and drops ``scoped`` (a merge
|
|
70
|
+
keeps everything). Formats whose whole payload IS the target scope leave
|
|
71
|
+
them at their defaults.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
head: int # stream offset where the element's event begins (or will)
|
|
75
|
+
end: int # just past the existing event; == head when the element is absent
|
|
76
|
+
payload: bytes | None # the existing element's payload; None = no event yet
|
|
77
|
+
scoped: bytes | None = None # target-scoped subset of payload; None = all of it
|
|
78
|
+
rest: bytes = b"" # payload outside the scope, in file order
|
|
79
|
+
prelude: bytes = b"" # event bytes spliced ahead when creating the element
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Format(Protocol):
|
|
83
|
+
"""The contract a .flp element type implements. Reading an implementation
|
|
84
|
+
tells you how that element is found, packed, unpacked, and checked -
|
|
85
|
+
nothing hidden in the engine. An instance is call-scoped: ``locate`` runs
|
|
86
|
+
first and may capture in-file templates (a flags word, a record template,
|
|
87
|
+
a header/trailer) that ``encode`` then carries (detect-don't-assume)."""
|
|
88
|
+
|
|
89
|
+
name: str # "notes", "playlist", "automation", "levels", "tempo", "effects"
|
|
90
|
+
event_id: int # the FLP event that carries it
|
|
91
|
+
frame: Callable[[bytes], bytes] | None # custom event framing, if needed
|
|
92
|
+
|
|
93
|
+
def locate(self, stream: Stream, target: Target) -> SpliceSite:
|
|
94
|
+
"""WHERE this element lives (e.g. notes: the pattern's blob)."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
def encode(self, data: Sequence[Any], ppq: int) -> bytes:
|
|
98
|
+
"""Tool units -> the element's file bytes (validating the input)."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def decode(self, blob: bytes, ppq: int) -> list[Any]:
|
|
102
|
+
"""File bytes -> tool units; byte-faithful (encode of the result
|
|
103
|
+
reproduces ``blob`` exactly), so kept items survive any rewrite."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
def verify(self, sent: Sequence[Any], readback: Sequence[Any]) -> None:
|
|
107
|
+
"""Raise FlpError unless the saved file decodes to exactly ``sent``."""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
def read(
|
|
111
|
+
path: Path,
|
|
112
|
+
fmt: Format,
|
|
113
|
+
target: Target,
|
|
114
|
+
*,
|
|
115
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
116
|
+
) -> list[Any]:
|
|
117
|
+
"""locate -> decode. The only element read path."""
|
|
118
|
+
_header, ppq, stream = _open(path.read_bytes(), event_size_overrides)
|
|
119
|
+
site = fmt.locate(stream, target)
|
|
120
|
+
return fmt.decode(_scope(site), ppq)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def patch(
|
|
124
|
+
path: Path,
|
|
125
|
+
fmt: Format,
|
|
126
|
+
target: Target,
|
|
127
|
+
data: Sequence[Any],
|
|
128
|
+
mode: Mode,
|
|
129
|
+
*,
|
|
130
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
131
|
+
) -> list[Any]:
|
|
132
|
+
"""locate -> encode -> splice + fix chunk lengths -> re-read -> fmt.verify.
|
|
133
|
+
|
|
134
|
+
The only write path. The new payload is ``encode`` over the kept items
|
|
135
|
+
(all of them on merge, only the out-of-scope ``rest`` on replace) plus
|
|
136
|
+
``data``; the readback IS the contract - the saved file must decode to
|
|
137
|
+
exactly what was spliced, or FlpError. Returns the target-scoped readback.
|
|
138
|
+
"""
|
|
139
|
+
if mode not in ("merge", "replace"):
|
|
140
|
+
raise FlpError(f"unsupported patch mode {mode!r}; expected 'merge' or 'replace'")
|
|
141
|
+
|
|
142
|
+
raw = bytearray(path.read_bytes())
|
|
143
|
+
header, ppq, stream = _open(bytes(raw), event_size_overrides)
|
|
144
|
+
base = 8 + len(header) + 8 # the FLdt payload's file offset
|
|
145
|
+
|
|
146
|
+
site = fmt.locate(stream, target)
|
|
147
|
+
kept_blob = site.payload if mode == "merge" else site.rest
|
|
148
|
+
kept = fmt.decode(kept_blob, ppq) if kept_blob else []
|
|
149
|
+
payload = fmt.encode([*kept, *data], ppq)
|
|
150
|
+
|
|
151
|
+
frame = fmt.frame
|
|
152
|
+
event = site.prelude + (frame(payload) if frame is not None else frame_event(fmt.event_id, payload))
|
|
153
|
+
raw[base + site.head : base + site.end] = event
|
|
154
|
+
_bump_fldt_length(raw, len(event) - (site.end - site.head))
|
|
155
|
+
path.write_bytes(bytes(raw))
|
|
156
|
+
|
|
157
|
+
_, _, saved_stream = _open(path.read_bytes(), event_size_overrides)
|
|
158
|
+
saved = fmt.locate(saved_stream, target)
|
|
159
|
+
fmt.verify(fmt.decode(payload, ppq), fmt.decode(saved.payload or b"", ppq))
|
|
160
|
+
return fmt.decode(_scope(saved), ppq)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def verify_identical(sent: Sequence[Any], readback: Sequence[Any], context: str) -> None:
|
|
164
|
+
"""The one readback contract: the saved element decodes to exactly what
|
|
165
|
+
was sent - same items, same order. Raises FlpError naming the first drift."""
|
|
166
|
+
if list(readback) == list(sent):
|
|
167
|
+
return
|
|
168
|
+
drift = next(
|
|
169
|
+
(f"item {i}: sent {s!r}, read back {r!r}" for i, (s, r) in enumerate(zip(sent, readback)) if s != r),
|
|
170
|
+
f"sent {len(sent)} items, read back {len(readback)}",
|
|
171
|
+
)
|
|
172
|
+
raise FlpError(f"readback of {context} does not match the write: {drift}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _scope(site: SpliceSite) -> bytes:
|
|
176
|
+
return site.scoped if site.scoped is not None else (site.payload or b"")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _open(data: bytes, overrides: Mapping[int, int] | None) -> tuple[bytes, int, Stream]:
|
|
180
|
+
header, raw = chunks(data)
|
|
181
|
+
return header, int.from_bytes(header[4:6], "little"), Stream(raw, overrides)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# -- the wire format ----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def chunks(data: bytes) -> tuple[bytes, memoryview]:
|
|
188
|
+
"""Split FLhd header / FLdt event stream; raises FlpError on a non-FLP file.
|
|
189
|
+
|
|
190
|
+
Layout: b"FLhd" + u32 len(6) + (u16 format, u16 nChannels, u16 ppq),
|
|
191
|
+
then b"FLdt" + u32 len + the event stream.
|
|
192
|
+
"""
|
|
193
|
+
if data[:4] != b"FLhd":
|
|
194
|
+
raise FlpError("not an FLP file: no FLhd magic at offset 0")
|
|
195
|
+
if len(data) < 8:
|
|
196
|
+
raise FlpError(f"truncated FLhd chunk: file is only {len(data)} bytes")
|
|
197
|
+
hd_len = int.from_bytes(data[4:8], "little")
|
|
198
|
+
if hd_len < 6 or 8 + hd_len > len(data):
|
|
199
|
+
raise FlpError(f"bad FLhd length {hd_len} at offset 4")
|
|
200
|
+
header = data[8 : 8 + hd_len]
|
|
201
|
+
# pyflp's FileFormat set: None_/Project/Score/Automation/ChannelState/
|
|
202
|
+
# PluginState/GeneratorState/FXState/InsertState/Patcher.
|
|
203
|
+
file_format = int.from_bytes(header[0:2], "little", signed=True)
|
|
204
|
+
if file_format not in (-1, 0, 0x10, 24, 0x20, 0x30, 0x31, 0x32, 0x40, 0x50):
|
|
205
|
+
raise FlpError(f"unknown FLP format {file_format} at offset 8")
|
|
206
|
+
dt_off = 8 + hd_len
|
|
207
|
+
if data[dt_off : dt_off + 4] != b"FLdt":
|
|
208
|
+
raise FlpError(f"no FLdt magic at offset {dt_off}")
|
|
209
|
+
if dt_off + 8 > len(data):
|
|
210
|
+
raise FlpError(f"truncated FLdt chunk header at offset {dt_off}")
|
|
211
|
+
dt_len = int.from_bytes(data[dt_off + 4 : dt_off + 8], "little")
|
|
212
|
+
start = dt_off + 8
|
|
213
|
+
if start + dt_len > len(data):
|
|
214
|
+
raise FlpError(
|
|
215
|
+
f"FLdt length {dt_len} at offset {dt_off + 4} overruns the file ({len(data)} bytes)"
|
|
216
|
+
)
|
|
217
|
+
return header, memoryview(data)[start : start + dt_len]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Stream:
|
|
221
|
+
"""The FLdt event stream: slices like a memoryview, iterates as events.
|
|
222
|
+
|
|
223
|
+
Event encoding: id < 64 -> 1 data byte, 64-127 -> 2, 128-191 -> 4,
|
|
224
|
+
192+ -> varint length + payload, except the measured size-override table
|
|
225
|
+
(resolved by detect.py; a capability profile's detected table wins over
|
|
226
|
+
the built-in fallback). Iteration yields (event_id, payload_offset,
|
|
227
|
+
payload_size), offsets relative to the stream; raises FlpError on
|
|
228
|
+
truncation.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(self, raw: memoryview | bytes, size_overrides: Mapping[int, int] | None = None):
|
|
232
|
+
self.raw = raw if isinstance(raw, memoryview) else memoryview(raw)
|
|
233
|
+
self.size_overrides = resolve_size_overrides(size_overrides)
|
|
234
|
+
|
|
235
|
+
def __getitem__(self, key):
|
|
236
|
+
return self.raw[key]
|
|
237
|
+
|
|
238
|
+
def __len__(self) -> int:
|
|
239
|
+
return len(self.raw)
|
|
240
|
+
|
|
241
|
+
def __iter__(self) -> Iterator[tuple[int, int, int]]:
|
|
242
|
+
stream, overrides, end = self.raw, self.size_overrides, len(self.raw)
|
|
243
|
+
pos = 0
|
|
244
|
+
while pos < end:
|
|
245
|
+
head = pos
|
|
246
|
+
event_id = stream[pos]
|
|
247
|
+
pos += 1
|
|
248
|
+
if event_id in overrides:
|
|
249
|
+
size = overrides[event_id]
|
|
250
|
+
elif event_id < 192:
|
|
251
|
+
size = 1 if event_id < 64 else 2 if event_id < 128 else 4
|
|
252
|
+
else:
|
|
253
|
+
size = 0
|
|
254
|
+
shift = 0
|
|
255
|
+
while True:
|
|
256
|
+
if pos >= end:
|
|
257
|
+
raise FlpError(f"truncated varint for event {event_id} at stream offset {head}")
|
|
258
|
+
byte = stream[pos]
|
|
259
|
+
pos += 1
|
|
260
|
+
size |= (byte & 0x7F) << shift
|
|
261
|
+
shift += 7
|
|
262
|
+
if not byte & 0x80:
|
|
263
|
+
break
|
|
264
|
+
if pos + size > end:
|
|
265
|
+
raise FlpError(
|
|
266
|
+
f"event {event_id} payload of {size} bytes at stream offset {head} "
|
|
267
|
+
f"overruns the stream ({end} bytes)"
|
|
268
|
+
)
|
|
269
|
+
yield event_id, pos, size
|
|
270
|
+
pos += size
|
|
271
|
+
|
|
272
|
+
def channel_events(self) -> Iterator[tuple[int | None, int, int, int, int]]:
|
|
273
|
+
"""Iterate events with rack-channel attribution: yields (channel,
|
|
274
|
+
event_id, head, off, size), ``channel`` None outside the channel
|
|
275
|
+
section. The rule (corpus-verified on all 164 bundled projects):
|
|
276
|
+
CHANNEL 0 IS IMPLICIT - a channel-scoped event with no channel open
|
|
277
|
+
yet opens channel 0 (stock 'Basic with limiter' does this; found
|
|
278
|
+
live); New(64)=N opens/switches to channel N; the first mixer event
|
|
279
|
+
ends attribution (slot plugin names are not channel names)."""
|
|
280
|
+
current: int | None = None
|
|
281
|
+
mixer_seen = False
|
|
282
|
+
prev_end = 0
|
|
283
|
+
for event_id, off, size in self:
|
|
284
|
+
head = prev_end
|
|
285
|
+
prev_end = off + size
|
|
286
|
+
channel: int | None = None
|
|
287
|
+
if event_id == EVENT_CHANNEL_NEW and size == 2 and not mixer_seen:
|
|
288
|
+
current = channel = int.from_bytes(self.raw[off : off + size], "little")
|
|
289
|
+
elif event_id == EVENT_MIXER_FLAGS:
|
|
290
|
+
mixer_seen = True
|
|
291
|
+
current = None
|
|
292
|
+
elif is_channel_scoped(event_id) and not mixer_seen:
|
|
293
|
+
current = current if current is not None else 0
|
|
294
|
+
channel = current
|
|
295
|
+
yield channel, event_id, head, off, size
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def frame_event(event_id: int, payload: bytes) -> bytes:
|
|
299
|
+
"""Frame one event: fixed-size payload below id 192, else a varint length
|
|
300
|
+
(7 bits per byte, high bit = continue) then the payload."""
|
|
301
|
+
if event_id < 192:
|
|
302
|
+
return bytes([event_id]) + payload
|
|
303
|
+
length = bytearray()
|
|
304
|
+
n = len(payload)
|
|
305
|
+
while True:
|
|
306
|
+
length.append((n & 0x7F) | (0x80 if n > 0x7F else 0))
|
|
307
|
+
n >>= 7
|
|
308
|
+
if not n:
|
|
309
|
+
break
|
|
310
|
+
return bytes([event_id]) + bytes(length) + payload
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _bump_fldt_length(data: bytearray, delta: int) -> None:
|
|
314
|
+
hd_len = int.from_bytes(data[4:8], "little")
|
|
315
|
+
dt_len_off = 8 + hd_len + 4
|
|
316
|
+
old_len = int.from_bytes(data[dt_len_off : dt_len_off + 4], "little")
|
|
317
|
+
data[dt_len_off : dt_len_off + 4] = (old_len + delta).to_bytes(4, "little")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Fruity Parametric EQ 2",
|
|
3
|
+
"fl_build": "26.1.5.5384",
|
|
4
|
+
"kind": "mixer-effect",
|
|
5
|
+
"captured_on": "2026-09-01",
|
|
6
|
+
"sha256": "cb3358762c19cab2b91977938f62e100db1ee4a0fa4d4e228d6c79cdf1685cd5",
|
|
7
|
+
"chunk_hex": "c92e460072007500690074007900200050006100720061006d0065007400720069006300200045005100200032000000d434000000000100000002000000000000004001000000000000000000000000000000000000540000007e000000bc0200005e010000cb2e460072007500690074007900200050006100720061006d00650074007200690063002000450051002000320000009b0000000080485156002900d5e2020800000000000000000000000000000000000000000000000000000000000000ab2a00001c4700008e63000000800000729c0000e4b8000055d50000bc9c00004463000044630000446300004463000044630000bc9c000005000000060000000600000006000000060000000600000007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ab2a00001c4700008e63000000800000729c0000e4b8000055d50000bc9c00004463000044630000446300004463000044630000bc9c000005000000060000000600000006000000060000000600000007000000000000000000000000000000000000000000000000000000000000000000000003000000000000000102000000010000000200000000000000407f0000000100000000000000030000000100000002000000020000000200000000000000"
|
|
8
|
+
}
|
flpkit/detect.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Signature detection: the ONLY place flpkit infers a size or a stride.
|
|
2
|
+
|
|
3
|
+
FL grows record layouts era by era without version-tagging them, so every
|
|
4
|
+
era-variable size here is DETECTED from an in-data signature, with a
|
|
5
|
+
hardcoded FL-2026-measured table as the warned fallback (detect-don't-assume).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import logging
|
|
12
|
+
import struct
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("flpkit")
|
|
16
|
+
|
|
17
|
+
# Events whose size breaks the classic range rule. 172 is a ONE-byte event:
|
|
18
|
+
# verified by full-stream consistency on 16 FL-2026-authored files (every one
|
|
19
|
+
# consumes exactly, with ChannelID.New counts matching the FLhd header) and on
|
|
20
|
+
# the two shipped templates that contain it - under the classic 4-byte read,
|
|
21
|
+
# "Basic with limiter" loses a channel AND its tempo event to the desync.
|
|
22
|
+
# pyflp shares the 4-byte bug (it cannot parse fresh FL 2026 saves at all),
|
|
23
|
+
# so this table is where flpkit deliberately diverges from the oracle.
|
|
24
|
+
# It is the FALLBACK: every public function takes ``event_size_overrides`` so
|
|
25
|
+
# a capability profile's offline-detected table wins over this hardcoded one;
|
|
26
|
+
# falling back here is logged once per process.
|
|
27
|
+
EVENT_SIZE_OVERRIDES_FALLBACK: Mapping[int, int] = {172: 1}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@functools.cache
|
|
31
|
+
def _log_size_override_fallback() -> None:
|
|
32
|
+
log.info(
|
|
33
|
+
"no event_size_overrides provided; falling back to the built-in "
|
|
34
|
+
"FL-2026-measured table %r (a capability profile can supply the "
|
|
35
|
+
"detected table for other FL versions)",
|
|
36
|
+
EVENT_SIZE_OVERRIDES_FALLBACK,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_size_overrides(overrides: Mapping[int, int] | None) -> Mapping[int, int]:
|
|
41
|
+
if overrides is not None:
|
|
42
|
+
return overrides
|
|
43
|
+
_log_size_override_fallback()
|
|
44
|
+
return EVENT_SIZE_OVERRIDES_FALLBACK
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Playlist item records: the 16-byte decoded head is constant across eras -
|
|
48
|
+
# position u32, pattern_base u16 (ALWAYS 20480 - the per-record signature),
|
|
49
|
+
# item_index u16 (pattern iid+20480, else channel iid), length u32,
|
|
50
|
+
# track_rvidx u16 (reversed: track 1 = 499 in the 500-track space), group u16.
|
|
51
|
+
# The RECORD SIZE grows era by era: 32 (classic), 60 (FL 21), 80 (observed in
|
|
52
|
+
# 17 of FL's bundled demos), 88 (FL 2026, decoded exactly against a project
|
|
53
|
+
# whose playlist visibly held 4 clips). Rather than hardcode that list, the
|
|
54
|
+
# stride is DETECTED: the smallest 4-byte-aligned size >= 32 that divides the
|
|
55
|
+
# blob and puts pattern_base == 20480 (and a sane track) at EVERY record head.
|
|
56
|
+
# All 49 corpus files with playlists resolve unambiguously under this rule.
|
|
57
|
+
PLAYLIST_STRIDE_MIN = 32
|
|
58
|
+
PLAYLIST_STRIDE_MAX = 256
|
|
59
|
+
PLAYLIST_TRACK_SPACE = 500 # track number = PLAYLIST_TRACK_SPACE - track_rvidx
|
|
60
|
+
PATTERN_INDEX_BASE = 20480 # item_index >= this means a pattern clip
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _stride_fits(blob: bytes, stride: int) -> bool:
|
|
64
|
+
"""True when every stride-aligned record head carries the pattern_base
|
|
65
|
+
signature (20480) and a track index inside the 500-track space."""
|
|
66
|
+
for at in range(0, len(blob), stride):
|
|
67
|
+
(base,) = struct.unpack_from("<H", blob, at + 4)
|
|
68
|
+
(rvidx,) = struct.unpack_from("<H", blob, at + 12)
|
|
69
|
+
if base != PATTERN_INDEX_BASE or not 0 <= PLAYLIST_TRACK_SPACE - rvidx <= PLAYLIST_TRACK_SPACE:
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def playlist_stride(blob: bytes) -> int | None:
|
|
75
|
+
"""The detected record size of a playlist blob, None when nothing fits."""
|
|
76
|
+
return next(
|
|
77
|
+
(
|
|
78
|
+
s
|
|
79
|
+
for s in range(PLAYLIST_STRIDE_MIN, PLAYLIST_STRIDE_MAX + 1, 4)
|
|
80
|
+
if len(blob) % s == 0 and _stride_fits(blob, s)
|
|
81
|
+
),
|
|
82
|
+
None,
|
|
83
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""DATA-like Format implementations: each module states ONE .flp element -
|
|
2
|
+
how it is found (locate), packed (encode), unpacked (decode), and checked
|
|
3
|
+
(verify). Reading one module IS that feature; the engine is codec.py."""
|
|
4
|
+
|
|
5
|
+
from .automation import AutomationFormat
|
|
6
|
+
from .effects import EffectFormat
|
|
7
|
+
from .levels import LevelsFormat
|
|
8
|
+
from .notes import NotesFormat
|
|
9
|
+
from .playlist import PlaylistFormat
|
|
10
|
+
from .tempo import TempoFormat
|
|
11
|
+
|
|
12
|
+
# Every Format this library ships, for generic iteration (e.g. round-trip
|
|
13
|
+
# suites). Classes, not instances: a Format instance is call-scoped (its
|
|
14
|
+
# locate captures in-file templates), so construct one per use.
|
|
15
|
+
ALL = (NotesFormat, PlaylistFormat, AutomationFormat, LevelsFormat, TempoFormat, EffectFormat)
|
|
16
|
+
|
|
17
|
+
__all__ = ["ALL", "AutomationFormat", "EffectFormat", "LevelsFormat", "NotesFormat", "PlaylistFormat", "TempoFormat"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Automation: a type-5 channel's points blob (event 234).
|
|
2
|
+
|
|
3
|
+
Blob layout (pyflp-verified field map, corpus-verified sizes): 17B header,
|
|
4
|
+
point count u32 at 17, then 24B points - x_delta f64 (BEATS from the previous
|
|
5
|
+
point; corpus positions land on clean beat fractions), value f64 (0..1),
|
|
6
|
+
tension f32, 4B tension-linked tail. After the points FL writes a trailer
|
|
7
|
+
(112 bytes in 1098 of 1100 corpus blobs, 136 in 2). Writes are correct by
|
|
8
|
+
construction, not by decoding: the header and the era trailer are carried
|
|
9
|
+
verbatim from the channel's own blob, only the count and the point records
|
|
10
|
+
between them are rebuilt. A channel that is not kind 5 or has no blob is an
|
|
11
|
+
error, not an invitation to fabricate: creating a NEW clip needs live
|
|
12
|
+
minimal-pair evidence this writer deliberately does not guess at.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import struct
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Protocol
|
|
22
|
+
|
|
23
|
+
from flpkit.codec import (
|
|
24
|
+
EVENT_CHANNEL_AUTOMATION,
|
|
25
|
+
EVENT_CHANNEL_TYPE,
|
|
26
|
+
FlpError,
|
|
27
|
+
SpliceSite,
|
|
28
|
+
Stream,
|
|
29
|
+
Target,
|
|
30
|
+
verify_identical,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("flpkit")
|
|
34
|
+
|
|
35
|
+
CHANNEL_TYPE_AUTOMATION = 5 # corpus: all 1100 automation blobs sit in type-5 channels
|
|
36
|
+
_COUNT_AT = 17
|
|
37
|
+
_POINTS_AT = 21
|
|
38
|
+
_POINT_SIZE = 24
|
|
39
|
+
_TAIL_AT = 20 # tail offset inside one 24-byte point record
|
|
40
|
+
# The tail FL writes on a plain point (corpus: 1,093 of 9,676 points carry
|
|
41
|
+
# exactly this, typically a clip's first point; the rest vary per point, which
|
|
42
|
+
# is why decoded points carry their own tail bytes for byte-faithful rewrites).
|
|
43
|
+
AUTOMATION_TAIL_DEFAULT = bytes(4)
|
|
44
|
+
# FL itself stores values a hair outside 0..1 (corpus max 1.0000403), so input
|
|
45
|
+
# validation allows the same slack rather than refusing FL's own data.
|
|
46
|
+
_VALUE_SLACK = 0.001
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AutomationPointLike(Protocol):
|
|
50
|
+
"""What a caller-supplied point needs: ``position`` is beats from clip
|
|
51
|
+
start (absolute; the writer converts back to FL's stored x-deltas),
|
|
52
|
+
``value`` is normalized 0..1, ``tension`` -1..1. A point may also carry a
|
|
53
|
+
4-byte ``tail`` attribute (FL's opaque per-point tail); points without one
|
|
54
|
+
get the plain-point default."""
|
|
55
|
+
|
|
56
|
+
position: float
|
|
57
|
+
value: float
|
|
58
|
+
tension: float
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class AutomationPointSpec:
|
|
63
|
+
"""A ready-made AutomationPointLike for callers without their own model."""
|
|
64
|
+
|
|
65
|
+
position: float
|
|
66
|
+
value: float
|
|
67
|
+
tension: float = 0.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class FlpAutomationPoint:
|
|
72
|
+
"""One decoded point, relative to the clip start. ``tail`` is FL's opaque
|
|
73
|
+
4-byte per-point tail, carried raw so a decoded clip rewrites
|
|
74
|
+
readback-identically; byte-identically for FL-authored blobs
|
|
75
|
+
(corpus-tested)."""
|
|
76
|
+
|
|
77
|
+
position: float # beats from clip start
|
|
78
|
+
value: float # normalized 0..1
|
|
79
|
+
tension: float
|
|
80
|
+
tail: bytes = AUTOMATION_TAIL_DEFAULT
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AutomationFormat:
|
|
84
|
+
name = "automation"
|
|
85
|
+
event_id = EVENT_CHANNEL_AUTOMATION
|
|
86
|
+
frame = None
|
|
87
|
+
|
|
88
|
+
def locate(self, stream: Stream, target: Target) -> SpliceSite:
|
|
89
|
+
"""The target channel's automation blob, refusing dishonest ground:
|
|
90
|
+
an unknown channel (IndexError), a non-type-5 channel, a channel with
|
|
91
|
+
no blob, or a blob whose count overruns it. Captures the header and
|
|
92
|
+
the era trailer for encode to carry verbatim."""
|
|
93
|
+
seen: set[int] = set()
|
|
94
|
+
kind = None
|
|
95
|
+
found = None
|
|
96
|
+
for channel, event_id, head, off, size in stream.channel_events():
|
|
97
|
+
if channel is None:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(channel)
|
|
100
|
+
if channel != target.channel:
|
|
101
|
+
continue
|
|
102
|
+
if event_id == EVENT_CHANNEL_TYPE and size == 1:
|
|
103
|
+
kind = stream[off]
|
|
104
|
+
elif event_id == EVENT_CHANNEL_AUTOMATION and found is None:
|
|
105
|
+
found = (head, off, size)
|
|
106
|
+
if target.channel not in seen:
|
|
107
|
+
raise IndexError(f"no channel {target.channel}; project has {len(seen)}")
|
|
108
|
+
if kind != CHANNEL_TYPE_AUTOMATION:
|
|
109
|
+
raise FlpError(
|
|
110
|
+
f"channel {target.channel} is kind {kind}, not an automation channel "
|
|
111
|
+
f"(kind {CHANNEL_TYPE_AUTOMATION}); refusing to write points into it"
|
|
112
|
+
)
|
|
113
|
+
if found is None:
|
|
114
|
+
raise FlpError(
|
|
115
|
+
f"channel {target.channel} has no automation blob (event "
|
|
116
|
+
f"{EVENT_CHANNEL_AUTOMATION}); only existing clips are patched - "
|
|
117
|
+
"creating one needs undecoded header/trailer/link bytes"
|
|
118
|
+
)
|
|
119
|
+
head, off, size = found
|
|
120
|
+
blob = bytes(stream[off : off + size])
|
|
121
|
+
if len(blob) < _POINTS_AT:
|
|
122
|
+
raise FlpError(
|
|
123
|
+
f"channel {target.channel} automation blob of {len(blob)} bytes is "
|
|
124
|
+
"truncated; no honest header to carry"
|
|
125
|
+
)
|
|
126
|
+
(count,) = struct.unpack_from("<I", blob, _COUNT_AT)
|
|
127
|
+
points_end = _POINTS_AT + count * _POINT_SIZE
|
|
128
|
+
if points_end > len(blob):
|
|
129
|
+
raise FlpError(
|
|
130
|
+
f"channel {target.channel} automation blob claims {count} points but "
|
|
131
|
+
f"holds {len(blob)} bytes; refusing to guess where the trailer starts"
|
|
132
|
+
)
|
|
133
|
+
self._header = blob[:_COUNT_AT]
|
|
134
|
+
self._trailer = blob[points_end:]
|
|
135
|
+
return SpliceSite(head, off + size, blob)
|
|
136
|
+
|
|
137
|
+
def encode(self, data: Sequence[AutomationPointLike], ppq: int) -> bytes:
|
|
138
|
+
"""Points -> header (verbatim) + count + 24B records + trailer
|
|
139
|
+
(verbatim). Points are sorted by position first; FL stores forward
|
|
140
|
+
x-deltas (zero deltas, i.e. vertical steps, are fine)."""
|
|
141
|
+
ordered = sorted(data, key=lambda p: p.position)
|
|
142
|
+
for p in ordered:
|
|
143
|
+
if not -_VALUE_SLACK <= p.value <= 1 + _VALUE_SLACK:
|
|
144
|
+
raise ValueError(f"point value {p.value} out of range (normalized 0..1)")
|
|
145
|
+
if not -1.0 <= p.tension <= 1.0:
|
|
146
|
+
raise ValueError(f"point tension {p.tension} out of range (-1..1)")
|
|
147
|
+
tail = getattr(p, "tail", None)
|
|
148
|
+
if tail is not None and len(tail) != 4:
|
|
149
|
+
raise ValueError(f"point tail must be 4 bytes, got {len(tail)}")
|
|
150
|
+
if ordered and ordered[0].position < 0:
|
|
151
|
+
raise ValueError(f"point position {ordered[0].position} is negative")
|
|
152
|
+
body = bytearray(self._header)
|
|
153
|
+
body += struct.pack("<I", len(ordered))
|
|
154
|
+
prev = 0.0
|
|
155
|
+
for p in ordered:
|
|
156
|
+
tail = getattr(p, "tail", None)
|
|
157
|
+
body += struct.pack("<ddf", p.position - prev, p.value, p.tension)
|
|
158
|
+
body += bytes(tail) if tail is not None else AUTOMATION_TAIL_DEFAULT
|
|
159
|
+
prev = p.position
|
|
160
|
+
return bytes(body) + self._trailer
|
|
161
|
+
|
|
162
|
+
def decode(self, blob: bytes, ppq: int) -> list[FlpAutomationPoint]:
|
|
163
|
+
"""Points with cumulative-sum x_delta -> absolute positions. A
|
|
164
|
+
malformed blob decodes to no points, with a warning - the project
|
|
165
|
+
reader's never-crash contract (the write path refuses these in
|
|
166
|
+
locate, before any byte moves)."""
|
|
167
|
+
if len(blob) < _POINTS_AT:
|
|
168
|
+
log.warning("automation blob of %d bytes is truncated", len(blob))
|
|
169
|
+
return []
|
|
170
|
+
(count,) = struct.unpack_from("<I", blob, _COUNT_AT)
|
|
171
|
+
if _POINTS_AT + count * _POINT_SIZE > len(blob):
|
|
172
|
+
log.warning("automation blob claims %d points but holds %d bytes", count, len(blob))
|
|
173
|
+
return []
|
|
174
|
+
points = []
|
|
175
|
+
position = 0.0
|
|
176
|
+
for i in range(count):
|
|
177
|
+
at = _POINTS_AT + i * _POINT_SIZE
|
|
178
|
+
delta, value, tension = struct.unpack_from("<ddf", blob, at)
|
|
179
|
+
position += delta
|
|
180
|
+
points.append(
|
|
181
|
+
FlpAutomationPoint(
|
|
182
|
+
position=position, value=value, tension=tension,
|
|
183
|
+
tail=bytes(blob[at + _TAIL_AT : at + _POINT_SIZE]),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return points
|
|
187
|
+
|
|
188
|
+
def verify(self, sent: Sequence[FlpAutomationPoint], readback: Sequence[FlpAutomationPoint]) -> None:
|
|
189
|
+
verify_identical(sent, readback, "the channel's automation points")
|