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
|
@@ -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]
|
flpkit/formats/levels.py
ADDED
|
@@ -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")
|
flpkit/formats/notes.py
ADDED
|
@@ -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")
|