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.
@@ -0,0 +1,170 @@
1
+ """Captured mixer-effect references, spliced without interpreting plugin state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from hashlib import sha256
9
+ from importlib.resources import files
10
+
11
+ from flpkit.codec import EVENT_MIXER_FLAGS, FlpError, SpliceSite, Stream, Target, verify_identical
12
+
13
+ _INTERNAL_NAME = 201
14
+ _WRAPPER = 212
15
+ _STATE = 213
16
+ _SLOT = 98
17
+ _RESOURCE_DIR = ("data", "plugins")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PluginReference:
22
+ """One FL-authored opaque plugin tuple captured as data."""
23
+
24
+ name: str
25
+ chunk: bytes
26
+ fl_build: str
27
+ kind: str
28
+
29
+
30
+ class PluginDatabase:
31
+ """Lazy captured-reference lookup, indexed by plugin name."""
32
+
33
+ def __init__(self, references: Mapping[str, PluginReference] | None = None):
34
+ self._references = dict(references or {})
35
+ self._index: dict[str, str] | None = {} if references is not None else None
36
+
37
+ def reference(self, name: str) -> PluginReference:
38
+ if name in self._references:
39
+ return self._references[name]
40
+ if self._index is None:
41
+ root = files("flpkit").joinpath(*_RESOURCE_DIR)
42
+ self._index = json.loads(root.joinpath("index.json").read_text())
43
+ filename = self._index.get(name)
44
+ if filename is None:
45
+ raise FlpError(f"no FL-authored default-state reference for effect {name!r}")
46
+ record = json.loads(files("flpkit").joinpath(*_RESOURCE_DIR, filename).read_text())
47
+ try:
48
+ chunk = bytes.fromhex(record["chunk_hex"])
49
+ reference = PluginReference(record["name"], chunk, record["fl_build"], record["kind"])
50
+ except (KeyError, TypeError, ValueError) as error:
51
+ raise FlpError(f"invalid captured plugin reference {filename!r}") from error
52
+ if reference.name != name or sha256(chunk).hexdigest() != record.get("sha256"):
53
+ raise FlpError(f"captured plugin reference {filename!r} failed its integrity check")
54
+ self._references[name] = reference
55
+ return reference
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Effect:
60
+ """A decoded effect tuple whose plugin state remains opaque."""
61
+
62
+ name: str
63
+ chunk: bytes
64
+
65
+
66
+ DEFAULT_PLUGIN_DATABASE = PluginDatabase()
67
+
68
+
69
+ class EffectFormat:
70
+ """The proven empty Master slot 0 insertion, expressed as a Format."""
71
+
72
+ name = "effects"
73
+ event_id = _INTERNAL_NAME
74
+
75
+ def __init__(self, *, adding: bool = False):
76
+ self._adding = adding
77
+
78
+ def locate(self, stream: Stream, target: Target) -> SpliceSite:
79
+ if target.insert != 0:
80
+ raise FlpError("effect add is only FL-authored for Master insert 0")
81
+ events = _events(stream)
82
+ insert = next(
83
+ (
84
+ index
85
+ for index, (event_id, _head, _end, payload) in enumerate(events)
86
+ if event_id == EVENT_MIXER_FLAGS and len(payload) == 12
87
+ and int.from_bytes(payload[:4], "little") == target.insert
88
+ ),
89
+ None,
90
+ )
91
+ if insert is None:
92
+ raise FlpError(f"no mixer insert {target.insert}")
93
+ for index, (event_id, _head, _end, payload) in enumerate(events[insert + 1 :], insert + 1):
94
+ if event_id == EVENT_MIXER_FLAGS:
95
+ break
96
+ if event_id != _SLOT or payload != bytes(2):
97
+ continue
98
+ next_event = events[index + 1] if index + 1 < len(events) else None
99
+ if next_event is not None and next_event[0] == _SLOT:
100
+ return SpliceSite(next_event[1], next_event[1], None)
101
+ if next_event is not None and next_event[0] == _INTERNAL_NAME:
102
+ effect_head = next_event[1]
103
+ effect_end = _effect_end(events, index + 1)
104
+ if self._adding:
105
+ raise FlpError("mixer insert 0 has no confirmed empty slot 0")
106
+ return SpliceSite(effect_head, effect_end, bytes(stream[effect_head:effect_end]))
107
+ break
108
+ raise FlpError("mixer insert 0 has no confirmed empty slot 0")
109
+
110
+ def encode(self, data: Sequence[PluginReference], ppq: int) -> bytes:
111
+ if len(data) != 1:
112
+ raise FlpError(f"an effect insertion needs exactly one captured reference, got {len(data)}")
113
+ (reference,) = data
114
+ try:
115
+ decoded = self.decode(reference.chunk, ppq)
116
+ except (TypeError, AttributeError) as error:
117
+ raise FlpError("effect reference has no valid captured chunk") from error
118
+ if len(decoded) != 1 or decoded[0].name != reference.name:
119
+ raise FlpError(f"captured chunk does not identify {reference.name!r}")
120
+ # ``patch`` locates once more to verify its saved readback. Occupancy
121
+ # is only a pre-insertion requirement for this one-shot format.
122
+ self._adding = False
123
+ return reference.chunk
124
+
125
+ def frame(self, payload: bytes) -> bytes:
126
+ """Plugin references are captured event tuples, not synthesized events."""
127
+ return payload
128
+
129
+ def decode(self, blob: bytes, ppq: int) -> list[Effect]:
130
+ if not blob:
131
+ return []
132
+ try:
133
+ events = _events(Stream(blob))
134
+ if not events or events[0][0] != _INTERNAL_NAME:
135
+ raise FlpError("effect tuple does not start with PluginID.InternalName")
136
+ name = events[0][3].decode("utf-16-le").rstrip("\0")
137
+ end = _effect_end(events, 0)
138
+ return [Effect(name, blob[:end])]
139
+ except UnicodeDecodeError as error:
140
+ raise FlpError("effect name is not valid UTF-16-LE") from error
141
+
142
+ def verify(self, sent: Sequence[Effect], readback: Sequence[Effect]) -> None:
143
+ verify_identical(sent, readback, "the captured effect reference")
144
+
145
+
146
+ def _events(stream: Stream) -> list[tuple[int, int, int, bytes]]:
147
+ """Fully decoded events with their framing bounds."""
148
+ events = []
149
+ head = 0
150
+ for event_id, offset, size in stream:
151
+ end = offset + size
152
+ events.append((event_id, head, end, bytes(stream[offset:end])))
153
+ head = end
154
+ return events
155
+
156
+
157
+ def _effect_end(events: Sequence[tuple[int, int, int, bytes]], name_index: int) -> int:
158
+ if name_index + 1 >= len(events) or events[name_index + 1][0] != _WRAPPER:
159
+ raise FlpError("effect tuple has no plugin wrapper event")
160
+ state = next(
161
+ (
162
+ event
163
+ for event in events[name_index + 2 :]
164
+ if event[0] in (_STATE, _INTERNAL_NAME, EVENT_MIXER_FLAGS)
165
+ ),
166
+ None,
167
+ )
168
+ if state is not None and state[0] == _STATE:
169
+ return state[2]
170
+ return events[name_index + 1][2]
@@ -0,0 +1,97 @@
1
+ """Channel levels: ONE event (ChannelID.Levels, 219) with a fixed 24-byte
2
+ payload - pan i32 [0:4], volume u32 [4:8], pitch i32 [8:12], filter 12B -
3
+ verified by decoding FL 2026's own stock templates. FL stores fixed-point
4
+ ints; the tool surface is 0..1 volume and -1..1 pan so a model never has to
5
+ know that. A legacy project whose channel has no 219 event is refused
6
+ (FlpError) rather than written with guessed units.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+
15
+ from flpkit.codec import EVENT_CHANNEL_LEVELS, FlpError, SpliceSite, Stream, Target, verify_identical
16
+
17
+ LEVEL_MAX = 12800
18
+ PAN_CENTRE = 6400
19
+ VOLUME_DEFAULT = 10000
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Levels:
24
+ """One channel's mix state in tool units, plus the opaque 12-byte filter
25
+ tail carried raw so a rewrite is byte-identical."""
26
+
27
+ volume: float # 0..1
28
+ pan: float # -1..1, 0 = centre
29
+ pitch_semitones: int
30
+ tail: bytes = bytes(12)
31
+
32
+
33
+ class LevelsFormat:
34
+ name = "levels"
35
+ event_id = EVENT_CHANNEL_LEVELS
36
+ frame = None
37
+
38
+ def locate(self, stream: Stream, target: Target) -> SpliceSite:
39
+ """The target channel's Levels event (first 24-byte 219 attributed to
40
+ it). An unknown channel is IndexError; a channel without one is a
41
+ legacy-format refusal."""
42
+ seen: set[int] = set()
43
+ found = None
44
+ for channel, event_id, head, off, size in stream.channel_events():
45
+ if channel is None:
46
+ continue
47
+ seen.add(channel)
48
+ if (
49
+ event_id == EVENT_CHANNEL_LEVELS and size == 24
50
+ and channel == target.channel and found is None
51
+ ):
52
+ found = (head, off, size)
53
+ if target.channel not in seen:
54
+ raise IndexError(f"no channel {target.channel}; project has {len(seen)}")
55
+ if found is None:
56
+ raise FlpError(
57
+ f"channel {target.channel} has no Levels event (id 219); refusing to "
58
+ "write a legacy-format project with guessed units"
59
+ )
60
+ head, off, size = found
61
+ return SpliceSite(head, off + size, bytes(stream[off : off + size]))
62
+
63
+ def encode(self, data: Sequence[Levels], ppq: int) -> bytes:
64
+ """Exactly one Levels -> the 24-byte payload (tool units re-scaled to
65
+ FL's fixed-point ints, filter tail verbatim)."""
66
+ if len(data) != 1:
67
+ raise FlpError(f"a channel has exactly one Levels, got {len(data)}")
68
+ (levels,) = data
69
+ if not 0 <= levels.volume <= 1:
70
+ raise FlpError(f"volume {levels.volume} out of range (0.0-1.0)")
71
+ if not -1 <= levels.pan <= 1:
72
+ raise FlpError(f"pan {levels.pan} out of range (-1.0 to 1.0)")
73
+ if not -48 <= levels.pitch_semitones <= 48:
74
+ raise FlpError(f"pitch {levels.pitch_semitones} out of range (-48 to 48)")
75
+ return (
76
+ struct.pack(
77
+ "<iIi",
78
+ round(PAN_CENTRE + levels.pan * PAN_CENTRE),
79
+ round(levels.volume * LEVEL_MAX),
80
+ levels.pitch_semitones,
81
+ )
82
+ + levels.tail
83
+ )
84
+
85
+ def decode(self, blob: bytes, ppq: int) -> list[Levels]:
86
+ pan, volume, pitch = struct.unpack_from("<iIi", blob)
87
+ return [
88
+ Levels(
89
+ volume=volume / LEVEL_MAX,
90
+ pan=(pan - PAN_CENTRE) / PAN_CENTRE,
91
+ pitch_semitones=pitch,
92
+ tail=bytes(blob[12:]),
93
+ )
94
+ ]
95
+
96
+ def verify(self, sent: Sequence[Levels], readback: Sequence[Levels]) -> None:
97
+ verify_identical(sent, readback, "the channel's levels")
@@ -0,0 +1,219 @@
1
+ """Notes: a pattern's PatternID.Notes blob of 24-byte packed note records.
2
+
3
+ A pattern's blob holds EVERY channel's notes, so the target scope is one
4
+ channel within one pattern: replacing is scoped to the target channel
5
+ (measured: naive whole-blob replacement of one note on channel 0 wiped 51
6
+ notes on channel 3 of the same pattern).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import struct
13
+ from collections import Counter
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import Protocol
17
+
18
+ from flpkit.codec import (
19
+ EVENT_CHANNEL_NEW,
20
+ FlpError,
21
+ SpliceSite,
22
+ Stream,
23
+ Target,
24
+ frame_event,
25
+ is_channel_scoped,
26
+ verify_identical,
27
+ )
28
+
29
+ log = logging.getLogger("flpkit")
30
+
31
+ EVENT_PATTERN_NEW = 65 # u16 pattern number
32
+ EVENT_PATTERN_NOTES = 224 # varint-length blob of NOTE_STRUCT records
33
+ EVENT_CUR_GROUP_ID = 146 # ProjectID.CurGroupId; the anchor for a new pattern block
34
+
35
+ # 24-byte packed note record inside a PatternID.Notes blob (matches what FL
36
+ # writes): position u32 (ticks), flags u16, rack_channel u16, length u32
37
+ # (ticks), key u16 (MIDI), group u16, then fine_pitch, u1, release,
38
+ # midi_channel, pan, velocity, mod_x, mod_y - one byte each.
39
+ NOTE_STRUCT = struct.Struct("<IHHIHHBBBBBBBB")
40
+ NOTE_SIZE = 24
41
+
42
+ # Neutral per-note values FL writes for an ordinary note; wrong defaults show
43
+ # up as notes that play but behave oddly (silent, hard-panned, pitch-shifted).
44
+ FINE_PITCH_CENTER = 120
45
+ RELEASE_DEFAULT = 64
46
+ NOTE_PAN_CENTER = 64
47
+ MOD_DEFAULT = 128
48
+ # Every note FL itself writes carries bit 0x4000. Surveyed across the 132
49
+ # bundled FL 2026 projects that contain notes: 23,526 records with
50
+ # flags=0x4000 and 909 with 0x4008 - and NOT ONE with flags=0. New notes
51
+ # template their flags word from the target file's OWN notes when it has any
52
+ # (detect-don't-assume); this corpus constant is the fallback.
53
+ NOTE_FLAGS_DEFAULT = 0x4000
54
+
55
+
56
+ class NoteLike(Protocol):
57
+ """What a caller-supplied note needs, in tool units: ``key`` is MIDI
58
+ 0-127, ``start``/``length`` are beats, ``velocity``/``pan`` are 0..1
59
+ (pan 0.5 = centre). Any object with these attributes works."""
60
+
61
+ key: int
62
+ start: float
63
+ length: float
64
+ velocity: float
65
+ pan: float
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class NoteSpec:
70
+ """A ready-made NoteLike for callers without their own note model."""
71
+
72
+ key: int
73
+ start: float
74
+ length: float
75
+ velocity: float = 0.8
76
+ pan: float = 0.5
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Note(NoteSpec):
81
+ """One decoded note record, full-fidelity: the NoteLike tool units plus
82
+ every raw field the tool surface does not model, so a decoded note
83
+ re-encodes byte-identically (kept notes survive any rewrite untouched)."""
84
+
85
+ channel: int = 0
86
+ flags: int = NOTE_FLAGS_DEFAULT
87
+ group: int = 0
88
+ fine_pitch: int = FINE_PITCH_CENTER
89
+ u1: int = 0
90
+ release: int = RELEASE_DEFAULT
91
+ midi_channel: int = 0
92
+ mod_x: int = MOD_DEFAULT
93
+ mod_y: int = MOD_DEFAULT
94
+ raw_values: bool = field(default=False, repr=False, compare=False)
95
+
96
+
97
+ class NotesFormat:
98
+ name = "notes"
99
+ event_id = EVENT_PATTERN_NOTES
100
+ frame = None
101
+
102
+ def locate(self, stream: Stream, target: Target) -> SpliceSite:
103
+ """The target pattern's notes blob (only a 224 event while that
104
+ pattern is current counts), split into the target channel's records
105
+ and the rest. One walk also finds where a missing pattern block would
106
+ splice - after the display-group anchor, where FL itself writes
107
+ patterns (NOT stream offset 0: ahead of FLVersion, FL refuses the
108
+ file) - and surveys the file's own note-flags template."""
109
+ current = 0
110
+ prev_end = 0
111
+ pattern_end = existing = anchor_end = channel_section_head = None
112
+ flags_seen: Counter[int] = Counter()
113
+ for event_id, off, size in stream:
114
+ head = prev_end
115
+ prev_end = off + size
116
+ if event_id == EVENT_PATTERN_NEW and size == 2:
117
+ current = int.from_bytes(stream[off : off + size], "little")
118
+ if current == target.pattern and pattern_end is None:
119
+ pattern_end = off + size
120
+ elif event_id == EVENT_PATTERN_NOTES:
121
+ if size % NOTE_SIZE == 0:
122
+ flags_seen.update(
123
+ int.from_bytes(stream[off + at + 4 : off + at + 6], "little")
124
+ for at in range(0, size, NOTE_SIZE)
125
+ )
126
+ if current == target.pattern and existing is None:
127
+ existing = (head, off, size)
128
+ elif event_id == EVENT_CUR_GROUP_ID and anchor_end is None:
129
+ anchor_end = off + size
130
+ elif (
131
+ channel_section_head is None
132
+ and anchor_end is None
133
+ and (event_id == EVENT_CHANNEL_NEW or is_channel_scoped(event_id))
134
+ ):
135
+ channel_section_head = head
136
+ self._flags = flags_seen.most_common(1)[0][0] if flags_seen else None
137
+ self._channel = target.channel
138
+
139
+ if existing is not None:
140
+ head, off, size = existing
141
+ blob = bytes(stream[off : off + size])
142
+ mine, rest = bytearray(), bytearray()
143
+ for at in range(0, len(blob) - NOTE_SIZE + 1, NOTE_SIZE):
144
+ record = blob[at : at + NOTE_SIZE]
145
+ in_scope = int.from_bytes(record[6:8], "little") == target.channel
146
+ (mine if in_scope else rest).extend(record)
147
+ scoped = bytes(mine) if target.channel is not None else None
148
+ return SpliceSite(head, off + size, blob, scoped=scoped, rest=bytes(rest))
149
+ if pattern_end is not None:
150
+ return SpliceSite(pattern_end, pattern_end, None)
151
+ # No such pattern yet - splice a new pattern block ahead of the event.
152
+ prelude = frame_event(EVENT_PATTERN_NEW, int(target.pattern or 0).to_bytes(2, "little"))
153
+ at = anchor_end if anchor_end is not None else channel_section_head
154
+ at = at if at is not None else len(stream)
155
+ return SpliceSite(at, at, None, prelude=prelude)
156
+
157
+ def encode(self, data: Sequence[NoteLike], ppq: int) -> bytes:
158
+ """Tool-unit notes -> packed records (ticks; velocity/pan re-scaled).
159
+ Fields a plain NoteLike does not carry get FL's neutral defaults, the
160
+ flags word from the file's own template (locate surveyed it; the
161
+ corpus constant covers a file with no notes yet), and the rack channel
162
+ from the write target."""
163
+ flags = getattr(self, "_flags", None) # locate's survey; None standalone or file had no notes
164
+ if flags is None:
165
+ flags = NOTE_FLAGS_DEFAULT
166
+ log.debug("no existing notes to template flags from; using the corpus default 0x%04x", flags)
167
+ packed = bytearray()
168
+ for note in data:
169
+ if not 0 <= note.key <= 127:
170
+ raise FlpError(f"note key {note.key} out of range (0-127)")
171
+ if note.start < 0 or note.length < 0:
172
+ raise FlpError("note start and length must be non-negative")
173
+ # Decoded records carry raw values FL may itself place outside the
174
+ # public tool range. Preserve those opaque facts byte-for-byte.
175
+ raw_values = getattr(note, "raw_values", False)
176
+ if not raw_values and (not 0 <= note.velocity <= 1 or not 0 <= note.pan <= 1):
177
+ raise FlpError("note velocity and pan must be in range 0.0-1.0")
178
+ packed += NOTE_STRUCT.pack(
179
+ round(note.start * ppq),
180
+ getattr(note, "flags", flags),
181
+ getattr(note, "channel", getattr(self, "_channel", None)) or 0,
182
+ # Length packs exactly as quantized - NO floor to 1: FL's own
183
+ # demos carry length-0 records (29 corpus files, e.g. 601 in
184
+ # 'Astes - Bien Duro'), so kept notes must round-trip raw 0.
185
+ round(note.length * ppq),
186
+ note.key,
187
+ getattr(note, "group", 0),
188
+ getattr(note, "fine_pitch", FINE_PITCH_CENTER),
189
+ getattr(note, "u1", 0),
190
+ getattr(note, "release", RELEASE_DEFAULT),
191
+ getattr(note, "midi_channel", 0),
192
+ round(note.pan * 128),
193
+ round(note.velocity * 127),
194
+ getattr(note, "mod_x", MOD_DEFAULT),
195
+ getattr(note, "mod_y", MOD_DEFAULT),
196
+ )
197
+ return bytes(packed)
198
+
199
+ def decode(self, blob: bytes, ppq: int) -> list[Note]:
200
+ """Unpack records to full-fidelity notes in beats/0..1 tool units
201
+ (exact: every field re-encodes to the same bytes). Raises FlpError on
202
+ a truncated blob."""
203
+ if len(blob) % NOTE_SIZE:
204
+ raise FlpError(f"notes blob of {len(blob)} bytes is not a multiple of {NOTE_SIZE}")
205
+ return [
206
+ Note(
207
+ key=key, start=position / ppq, length=length / ppq,
208
+ velocity=velocity / 127, pan=pan / 128,
209
+ channel=channel, flags=flags, group=group, fine_pitch=fine_pitch,
210
+ u1=u1, release=release, midi_channel=midi_channel, mod_x=mod_x, mod_y=mod_y, raw_values=True,
211
+ )
212
+ for (
213
+ position, flags, channel, length, key, group,
214
+ fine_pitch, u1, release, midi_channel, pan, velocity, mod_x, mod_y,
215
+ ) in NOTE_STRUCT.iter_unpack(blob)
216
+ ]
217
+
218
+ def verify(self, sent: Sequence[Note], readback: Sequence[Note]) -> None:
219
+ verify_identical(sent, readback, "the pattern's notes")
@@ -0,0 +1,173 @@
1
+ """Playlist: an arrangement's PlaylistID.Items blob of fixed-stride records.
2
+
3
+ The 16-byte record head is constant across eras (see detect.py, which infers
4
+ the era's record size from the pattern_base==20480 signature); the rest of a
5
+ record is era-opaque, so every record is built from a byte TEMPLATE - the
6
+ first existing record in the target blob - and a playlist with no existing
7
+ record has no honest template (FlpError, never invented tail bytes).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import struct
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Protocol
16
+
17
+ from flpkit.codec import FlpError, SpliceSite, Stream, Target, verify_identical
18
+ from flpkit.detect import PATTERN_INDEX_BASE, PLAYLIST_TRACK_SPACE, playlist_stride
19
+
20
+ EVENT_ARRANGEMENT_NEW = 99 # u16 arrangement index; starts an arrangement block
21
+ EVENT_PLAYLIST = 233 # blob of playlist-item records
22
+ RECORD_HEAD = struct.Struct("<IHHIHH") # position, pattern_base, item_index, length, track_rvidx, group
23
+
24
+ # Bytes 24-31 are the clip's cut window in TICKS (u32 start, u32 end) - NOT
25
+ # the f32 offsets pyflp documents. Proven live: FL 2026 derives the visible
26
+ # clip length from this window (a zeroed window collapsed the clip to length 1
27
+ # and FL rewrote the head length field to match on its next save). An uncut
28
+ # clip spans 0..length. EXISTING records' windows are era-opaque bytes and
29
+ # are never touched or interpreted - legacy eras stamp f32 shapes there
30
+ # (corpus: FL-bundled demo/legacy projects), and the e2e matrix proved live
31
+ # that FL 2026 accepts our modern window on NEW records beside them.
32
+ _WINDOW_AT = 24
33
+
34
+
35
+ class ClipLike(Protocol):
36
+ """What a caller-supplied clip needs, in tool units: a pattern number
37
+ placed on a 1-based playlist track, ``start``/``length`` in beats."""
38
+
39
+ pattern: int
40
+ track: int
41
+ start: float
42
+ length: float
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ClipSpec:
47
+ """A ready-made ClipLike for callers without their own clip model."""
48
+
49
+ pattern: int
50
+ track: int
51
+ start: float
52
+ length: float
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Clip:
57
+ """One decoded record: the head in tool units plus the era-opaque tail
58
+ (record bytes 16..stride, cut window included) carried raw, so a decoded
59
+ clip re-encodes byte-identically."""
60
+
61
+ track: int
62
+ start: float # beats
63
+ length: float # beats
64
+ pattern: int | None = None # set for pattern clips
65
+ channel: int | None = None # set for audio/automation channel clips
66
+ group: int = 0
67
+ tail: bytes = b""
68
+
69
+
70
+ class PlaylistFormat:
71
+ name = "playlist"
72
+ event_id = EVENT_PLAYLIST
73
+ frame = None
74
+
75
+ def locate(self, stream: Stream, target: Target) -> SpliceSite:
76
+ """The target arrangement's playlist event (arrangement events switch
77
+ the current index; 0 before any). Captures the write template - the
78
+ first existing record; its window bytes are overwritten per new
79
+ clip, everything else is carried verbatim."""
80
+ arrangement = target.arrangement or 0
81
+ current = 0
82
+ prev_end = 0
83
+ existing = None
84
+ for event_id, off, size in stream:
85
+ head = prev_end
86
+ prev_end = off + size
87
+ if event_id == EVENT_ARRANGEMENT_NEW and size == 2:
88
+ current = int.from_bytes(stream[off : off + size], "little")
89
+ elif event_id == EVENT_PLAYLIST and current == arrangement and existing is None:
90
+ existing = (head, off, size)
91
+
92
+ self._template: bytes | None = None
93
+ self._refusal = (
94
+ f"arrangement {arrangement} has no playlist event; a write needs "
95
+ "one existing clip as a byte template"
96
+ )
97
+ if existing is None:
98
+ return SpliceSite(len(stream), len(stream), None)
99
+ head, off, size = existing
100
+ blob = bytes(stream[off : off + size])
101
+ stride = playlist_stride(blob)
102
+ if stride is None or not blob:
103
+ self._refusal = (
104
+ f"arrangement {arrangement}: playlist blob of {len(blob)} bytes "
105
+ "fits no known record size; no honest template"
106
+ )
107
+ return SpliceSite(head, off + size, blob)
108
+ self._template = blob[:stride]
109
+ self._refusal = None
110
+ return SpliceSite(head, off + size, blob)
111
+
112
+ def encode(self, data: Sequence[ClipLike], ppq: int) -> bytes:
113
+ """Tool-unit clips -> position-sorted records (all 54 corpus
114
+ arrangements are stored that way). New records inherit the template's
115
+ era tail with the uncut window packed in; decoded clips carry their
116
+ own tail verbatim."""
117
+ refusal = getattr(self, "_refusal", None) # set by locate on the write path
118
+ if refusal:
119
+ raise FlpError(refusal)
120
+ records = []
121
+ for clip in data:
122
+ if not 1 <= clip.track <= PLAYLIST_TRACK_SPACE:
123
+ raise ValueError(f"track {clip.track} outside 1..{PLAYLIST_TRACK_SPACE}")
124
+ # Exactly as quantized, no floor - decoded records must round-trip
125
+ # their raw length byte-identically (same rule as notes).
126
+ length = round(clip.length * ppq)
127
+ tail = getattr(clip, "tail", b"")
128
+ if not tail:
129
+ template = getattr(self, "_template", None)
130
+ if template is None:
131
+ raise FlpError("a new clip needs the located byte template; write through codec.patch")
132
+ tail = bytearray(template[RECORD_HEAD.size :])
133
+ struct.pack_into("<II", tail, _WINDOW_AT - RECORD_HEAD.size, 0, length)
134
+ pattern = getattr(clip, "pattern", None)
135
+ item_index = PATTERN_INDEX_BASE + pattern if pattern is not None else clip.channel
136
+ records.append(
137
+ RECORD_HEAD.pack(
138
+ round(clip.start * ppq), PATTERN_INDEX_BASE, item_index, length,
139
+ PLAYLIST_TRACK_SPACE - clip.track, getattr(clip, "group", 0),
140
+ )
141
+ + bytes(tail)
142
+ )
143
+ records.sort(key=lambda r: struct.unpack_from("<I", r)[0])
144
+ return b"".join(records)
145
+
146
+ def decode(self, blob: bytes, ppq: int) -> list[Clip]:
147
+ """Records -> tool-unit clips at the detected stride. Raises FlpError
148
+ when no stride fits (a new FL era) - the project reader surfaces that
149
+ as a per-event warning, never a crash."""
150
+ if not blob:
151
+ return []
152
+ stride = playlist_stride(blob)
153
+ if stride is None:
154
+ raise FlpError(f"playlist blob of {len(blob)} bytes fits no record size (new era?)")
155
+ clips = []
156
+ for at in range(0, len(blob), stride):
157
+ position, _base, item_index, length, track_rvidx, group = RECORD_HEAD.unpack_from(blob, at)
158
+ is_pattern = item_index >= PATTERN_INDEX_BASE
159
+ clips.append(
160
+ Clip(
161
+ track=PLAYLIST_TRACK_SPACE - track_rvidx,
162
+ start=position / ppq,
163
+ length=length / ppq,
164
+ pattern=item_index - PATTERN_INDEX_BASE if is_pattern else None,
165
+ channel=None if is_pattern else item_index,
166
+ group=group,
167
+ tail=blob[at + RECORD_HEAD.size : at + stride],
168
+ )
169
+ )
170
+ return clips
171
+
172
+ def verify(self, sent: Sequence[Clip], readback: Sequence[Clip]) -> None:
173
+ verify_identical(sent, readback, "the arrangement's playlist")