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/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
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "Fruity Parametric EQ 2": "fruity-parametric-eq-2.json"
3
+ }
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")