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/__init__.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Read and write FL Studio .flp project files, without FL Studio.
|
|
2
|
+
|
|
3
|
+
flpkit is a small, dependency-free library for the undocumented FLP format:
|
|
4
|
+
|
|
5
|
+
- **Reading**: ppq, tempo (modern and legacy events), channels (names + mix
|
|
6
|
+
levels across four format generations), notes per pattern/channel, playlist
|
|
7
|
+
clips per arrangement, and automation points per type-5 channel.
|
|
8
|
+
- **Writing**: raw byte surgery through ONE engine - ``codec.patch`` locates
|
|
9
|
+
an element, encodes the new bytes, splices them (fixing chunk lengths), and
|
|
10
|
+
verifies by re-reading the saved file. FL Studio rejects whole-file
|
|
11
|
+
reserialization by third-party writers (verified live 2026-08-27), so a
|
|
12
|
+
write patches exactly the bytes that express the change, never more.
|
|
13
|
+
|
|
14
|
+
Each element type is a Format spec under ``formats/`` (notes, playlist,
|
|
15
|
+
automation, levels, tempo, effects): reading one module tells you everything about
|
|
16
|
+
that element. Every constant is a reverse-engineered fact carrying its
|
|
17
|
+
evidence, verified against real FL Studio 2026 (macOS) and a corpus of 164
|
|
18
|
+
FL-authored projects; the live verification harness lives in the parent
|
|
19
|
+
project, https://github.com/origami-research/fl-studio-mcp.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import struct
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from importlib.metadata import version
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# ruff: noqa: F401
|
|
32
|
+
from . import codec, detect, formats
|
|
33
|
+
from .codec import (
|
|
34
|
+
EVENT_CHANNEL_AUTOMATION,
|
|
35
|
+
EVENT_CHANNEL_LEVELS,
|
|
36
|
+
EVENT_CHANNEL_NEW,
|
|
37
|
+
EVENT_CHANNEL_TYPE,
|
|
38
|
+
EVENT_MIXER_FLAGS,
|
|
39
|
+
EVENT_NAME_INTERNAL,
|
|
40
|
+
EVENT_NAME_LEGACY,
|
|
41
|
+
EVENT_NAME_USER,
|
|
42
|
+
EVENT_PAN_BYTE,
|
|
43
|
+
EVENT_PAN_WORD,
|
|
44
|
+
EVENT_VOL_BYTE,
|
|
45
|
+
EVENT_VOL_WORD,
|
|
46
|
+
FlpError,
|
|
47
|
+
Format,
|
|
48
|
+
Mode,
|
|
49
|
+
SpliceSite,
|
|
50
|
+
Target,
|
|
51
|
+
)
|
|
52
|
+
from .codec import chunks as _chunks
|
|
53
|
+
from .detect import (
|
|
54
|
+
EVENT_SIZE_OVERRIDES_FALLBACK,
|
|
55
|
+
PATTERN_INDEX_BASE,
|
|
56
|
+
PLAYLIST_STRIDE_MAX,
|
|
57
|
+
PLAYLIST_STRIDE_MIN,
|
|
58
|
+
PLAYLIST_TRACK_SPACE,
|
|
59
|
+
_log_size_override_fallback,
|
|
60
|
+
)
|
|
61
|
+
from .formats import AutomationFormat, EffectFormat, LevelsFormat, NotesFormat, PlaylistFormat, TempoFormat
|
|
62
|
+
from .formats.automation import (
|
|
63
|
+
AUTOMATION_TAIL_DEFAULT,
|
|
64
|
+
CHANNEL_TYPE_AUTOMATION,
|
|
65
|
+
AutomationPointLike,
|
|
66
|
+
AutomationPointSpec,
|
|
67
|
+
FlpAutomationPoint,
|
|
68
|
+
)
|
|
69
|
+
from .formats.effects import (
|
|
70
|
+
DEFAULT_PLUGIN_DATABASE,
|
|
71
|
+
Effect,
|
|
72
|
+
PluginDatabase,
|
|
73
|
+
PluginReference,
|
|
74
|
+
)
|
|
75
|
+
from .formats.levels import LEVEL_MAX, PAN_CENTRE, VOLUME_DEFAULT, Levels
|
|
76
|
+
from .formats.notes import (
|
|
77
|
+
EVENT_CUR_GROUP_ID,
|
|
78
|
+
EVENT_PATTERN_NEW,
|
|
79
|
+
EVENT_PATTERN_NOTES,
|
|
80
|
+
FINE_PITCH_CENTER,
|
|
81
|
+
MOD_DEFAULT,
|
|
82
|
+
NOTE_FLAGS_DEFAULT,
|
|
83
|
+
NOTE_PAN_CENTER,
|
|
84
|
+
NOTE_SIZE,
|
|
85
|
+
NOTE_STRUCT,
|
|
86
|
+
RELEASE_DEFAULT,
|
|
87
|
+
NoteLike,
|
|
88
|
+
NoteSpec,
|
|
89
|
+
)
|
|
90
|
+
from .formats.playlist import EVENT_ARRANGEMENT_NEW, EVENT_PLAYLIST, ClipLike, ClipSpec
|
|
91
|
+
from .formats.tempo import EVENT_TEMPO, EVENT_TEMPO_COARSE, EVENT_TEMPO_FINE
|
|
92
|
+
|
|
93
|
+
log = logging.getLogger("flpkit")
|
|
94
|
+
|
|
95
|
+
NoteWriteMode = Mode
|
|
96
|
+
EVENT_VERSION = 199 # ascii "major.minor..."; >= 11.5 -> text events are UTF-16-LE
|
|
97
|
+
PPQ_DEFAULT = 96
|
|
98
|
+
__version__ = version("flpkit")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _events(stream, event_size_overrides: Mapping[int, int] | None = None):
|
|
102
|
+
return iter(codec.Stream(stream, event_size_overrides))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Private v0.5.0 aliases still imported by mcp-server's tests/e2e/matrix.py;
|
|
106
|
+
# the integration manager retires them when matrix.py ports to codec/detect.
|
|
107
|
+
_is_channel_scoped = codec.is_channel_scoped
|
|
108
|
+
_playlist_stride_fits = detect._stride_fits
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _splice(data: bytearray, at: int, event: bytes) -> None:
|
|
112
|
+
"""Insert event bytes at ``at`` and bump the FLdt chunk length to match."""
|
|
113
|
+
data[at:at] = event
|
|
114
|
+
codec._bump_fldt_length(data, len(event))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -- the decoded read-only project view ---------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class FlpNote:
|
|
122
|
+
"""One decoded note, in file units (ticks)."""
|
|
123
|
+
|
|
124
|
+
position: int # ticks
|
|
125
|
+
length: int # ticks
|
|
126
|
+
key: int # MIDI 0-127
|
|
127
|
+
channel: int # rack channel index
|
|
128
|
+
velocity: int # 0-127
|
|
129
|
+
pan: int # 0-128, 64 = centre
|
|
130
|
+
# Which pattern the note came from (FL patterns are 1-based; 0 means the
|
|
131
|
+
# blob preceded any PatternID.New event, so the file did not attribute it).
|
|
132
|
+
pattern: int = 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class FlpChannel:
|
|
137
|
+
"""One decoded channel: identity plus mix levels in raw file units."""
|
|
138
|
+
|
|
139
|
+
index: int
|
|
140
|
+
name: str
|
|
141
|
+
volume: int # 0..LEVEL_MAX, VOLUME_DEFAULT if the file omits it
|
|
142
|
+
pan: int # 0..LEVEL_MAX, PAN_CENTRE if omitted
|
|
143
|
+
pitch_semitones: int
|
|
144
|
+
kind: int | None = None # EVENT_CHANNEL_TYPE byte; None when the file omits it
|
|
145
|
+
automation: tuple[FlpAutomationPoint, ...] = ()
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_automation(self) -> bool:
|
|
149
|
+
return self.kind == CHANNEL_TYPE_AUTOMATION
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class FlpPlaylistItem:
|
|
154
|
+
"""One clip on an arrangement's playlist, in file units (ticks)."""
|
|
155
|
+
|
|
156
|
+
position: int # ticks from song start
|
|
157
|
+
length: int # ticks
|
|
158
|
+
track: int # 1-based playlist track number
|
|
159
|
+
pattern: int | None # set for pattern clips
|
|
160
|
+
channel: int | None # set for audio/automation channel clips
|
|
161
|
+
group: int = 0
|
|
162
|
+
arrangement: int = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class ChannelLevels:
|
|
167
|
+
"""A channel's mix state in tool units - the readback of set_channel_levels."""
|
|
168
|
+
|
|
169
|
+
channel: int
|
|
170
|
+
volume: float # 0..1
|
|
171
|
+
pan: float # -1..1, 0 = centre
|
|
172
|
+
pitch_semitones: int
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class FlpProject:
|
|
177
|
+
"""Decoded read-only view of a project file."""
|
|
178
|
+
|
|
179
|
+
ppq: int
|
|
180
|
+
tempo: float | None # None = FL's default (the file omits the event)
|
|
181
|
+
channels: tuple[FlpChannel, ...]
|
|
182
|
+
notes: tuple[FlpNote, ...] # all patterns; filter by pattern via notes_at
|
|
183
|
+
playlist: tuple[FlpPlaylistItem, ...] = () # all arrangements' clips
|
|
184
|
+
|
|
185
|
+
def notes_in(self, pattern: int, channel: int | None = None) -> list[FlpNote]:
|
|
186
|
+
"""Notes in one pattern, optionally one channel."""
|
|
187
|
+
return [
|
|
188
|
+
note
|
|
189
|
+
for note in self.notes
|
|
190
|
+
if note.pattern == pattern and (channel is None or note.channel == channel)
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _flp_note(note: formats.notes.Note, ppq: int, pattern: int) -> FlpNote:
|
|
195
|
+
return FlpNote(
|
|
196
|
+
position=round(note.start * ppq),
|
|
197
|
+
length=round(note.length * ppq),
|
|
198
|
+
key=note.key,
|
|
199
|
+
channel=note.channel,
|
|
200
|
+
velocity=round(note.velocity * 127),
|
|
201
|
+
pan=round(note.pan * 128),
|
|
202
|
+
pattern=pattern,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _decode_notes(blob: bytes) -> list[FlpNote]:
|
|
207
|
+
return [_flp_note(n, PPQ_DEFAULT, 0) for n in NotesFormat().decode(blob, PPQ_DEFAULT)]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# -- the project reader --------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def read(path: Path, *, event_size_overrides: Mapping[int, int] | None = None) -> FlpProject:
|
|
214
|
+
"""Parse the file. Raises FlpError naming the byte offset on bad input.
|
|
215
|
+
|
|
216
|
+
``event_size_overrides`` maps event ids to their measured payload sizes
|
|
217
|
+
where FL breaks the classic range rule (a capability profile supplies it;
|
|
218
|
+
None falls back to the built-in FL-2026 table, logged once per process).
|
|
219
|
+
"""
|
|
220
|
+
header, raw = _chunks(path.read_bytes())
|
|
221
|
+
stream = codec.Stream(raw, event_size_overrides)
|
|
222
|
+
ppq = int.from_bytes(header[4:6], "little")
|
|
223
|
+
# CHANNEL 0 IS IMPLICIT (see Stream.channel_events). Verified corpus-wide:
|
|
224
|
+
# FLhd nChannels == |{0} union {New payloads}| on all 164 bundled projects.
|
|
225
|
+
n_channels = int.from_bytes(header[2:4], "little")
|
|
226
|
+
|
|
227
|
+
tempo: float | None = None
|
|
228
|
+
coarse: int | None = None # legacy tempo pair, used only when 156 is absent
|
|
229
|
+
fine = 0
|
|
230
|
+
unicode_text = False # flips when FLVersion says >= 11.5
|
|
231
|
+
channel_map: dict[int, dict] = {}
|
|
232
|
+
pattern = 0 # 0 = before any PatternID.New (A9: stock files do this)
|
|
233
|
+
notes: list[FlpNote] = []
|
|
234
|
+
arrangement = 0
|
|
235
|
+
playlist: list[FlpPlaylistItem] = []
|
|
236
|
+
|
|
237
|
+
for channel, event_id, _head, off, size in stream.channel_events():
|
|
238
|
+
payload = bytes(stream[off : off + size])
|
|
239
|
+
if channel is not None:
|
|
240
|
+
fields = channel_map.setdefault(channel, {"index": channel})
|
|
241
|
+
if event_id != EVENT_CHANNEL_NEW:
|
|
242
|
+
_apply_channel_event(fields, event_id, payload, unicode_text, off, ppq)
|
|
243
|
+
elif event_id == EVENT_VERSION:
|
|
244
|
+
unicode_text = _version_is_unicode(payload, off)
|
|
245
|
+
elif event_id == EVENT_TEMPO and size == 4:
|
|
246
|
+
tempo = int.from_bytes(payload, "little") / 1000
|
|
247
|
+
elif event_id == EVENT_TEMPO_COARSE and size == 2 and coarse is None:
|
|
248
|
+
coarse = int.from_bytes(payload, "little")
|
|
249
|
+
elif event_id == EVENT_TEMPO_FINE and size == 2 and fine == 0:
|
|
250
|
+
fine = int.from_bytes(payload, "little")
|
|
251
|
+
elif event_id == EVENT_PATTERN_NEW and size == 2:
|
|
252
|
+
pattern = int.from_bytes(payload, "little")
|
|
253
|
+
elif event_id == EVENT_ARRANGEMENT_NEW and size == 2:
|
|
254
|
+
arrangement = int.from_bytes(payload, "little")
|
|
255
|
+
elif event_id == EVENT_PLAYLIST:
|
|
256
|
+
try:
|
|
257
|
+
playlist.extend(
|
|
258
|
+
FlpPlaylistItem(
|
|
259
|
+
position=round(c.start * ppq), length=round(c.length * ppq),
|
|
260
|
+
track=c.track, pattern=c.pattern, channel=c.channel,
|
|
261
|
+
group=c.group, arrangement=arrangement,
|
|
262
|
+
)
|
|
263
|
+
for c in PlaylistFormat().decode(payload, ppq)
|
|
264
|
+
)
|
|
265
|
+
except FlpError as exc:
|
|
266
|
+
log.warning("%s: skipping playlist event at offset %d: %s", path.name, off, exc)
|
|
267
|
+
elif event_id == EVENT_PATTERN_NOTES:
|
|
268
|
+
if size % NOTE_SIZE:
|
|
269
|
+
log.warning(
|
|
270
|
+
"%s: skipping notes event of size %d (not a multiple of %d) "
|
|
271
|
+
"at stream offset %d",
|
|
272
|
+
path.name, size, NOTE_SIZE, off,
|
|
273
|
+
)
|
|
274
|
+
continue
|
|
275
|
+
notes.extend(_flp_note(n, ppq, pattern) for n in NotesFormat().decode(payload, ppq))
|
|
276
|
+
|
|
277
|
+
if tempo is None and coarse is not None:
|
|
278
|
+
tempo = coarse + fine / 1000 # pre-156 files store tempo as a word pair
|
|
279
|
+
if channel_map and len(channel_map) != n_channels:
|
|
280
|
+
log.warning(
|
|
281
|
+
"%s: decoded %d channels but the FLhd header says %d",
|
|
282
|
+
path.name, len(channel_map), n_channels,
|
|
283
|
+
)
|
|
284
|
+
return FlpProject(
|
|
285
|
+
ppq=ppq,
|
|
286
|
+
tempo=tempo,
|
|
287
|
+
channels=tuple(_build_channel(channel_map[iid]) for iid in sorted(channel_map)),
|
|
288
|
+
notes=tuple(notes),
|
|
289
|
+
playlist=tuple(playlist),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Raw-field keys a channel block accumulates before _build_channel resolves them.
|
|
294
|
+
_CHANNEL_EVENT_FIELDS = {
|
|
295
|
+
EVENT_NAME_USER: "name_user",
|
|
296
|
+
EVENT_NAME_LEGACY: "name_legacy",
|
|
297
|
+
EVENT_NAME_INTERNAL: "name_internal",
|
|
298
|
+
EVENT_VOL_WORD: "vol_word",
|
|
299
|
+
EVENT_PAN_WORD: "pan_word",
|
|
300
|
+
EVENT_VOL_BYTE: "vol_byte",
|
|
301
|
+
EVENT_PAN_BYTE: "pan_byte",
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _apply_channel_event(
|
|
306
|
+
fields: dict, event_id: int, payload: bytes, unicode_text: bool, offset: int, ppq: int
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Decode one channel-scoped event into the channel's raw-field dict."""
|
|
309
|
+
if event_id == EVENT_CHANNEL_LEVELS:
|
|
310
|
+
if len(payload) != 24:
|
|
311
|
+
log.warning(
|
|
312
|
+
"skipping Levels event of size %d (expected 24) at stream offset %d",
|
|
313
|
+
len(payload), offset,
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
pan, volume, pitch = struct.unpack_from("<iIi", payload)
|
|
317
|
+
fields.update(levels_pan=pan, levels_volume=volume, levels_pitch=pitch)
|
|
318
|
+
elif event_id in (EVENT_NAME_USER, EVENT_NAME_LEGACY, EVENT_NAME_INTERNAL):
|
|
319
|
+
fields[_CHANNEL_EVENT_FIELDS[event_id]] = _text(payload, unicode_text, offset)
|
|
320
|
+
elif event_id in (EVENT_VOL_WORD, EVENT_PAN_WORD, EVENT_VOL_BYTE, EVENT_PAN_BYTE):
|
|
321
|
+
fields[_CHANNEL_EVENT_FIELDS[event_id]] = int.from_bytes(payload, "little")
|
|
322
|
+
elif event_id == EVENT_CHANNEL_TYPE and len(payload) == 1:
|
|
323
|
+
fields["kind"] = payload[0]
|
|
324
|
+
elif event_id == EVENT_CHANNEL_AUTOMATION:
|
|
325
|
+
fields["automation"] = tuple(AutomationFormat().decode(payload, ppq))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _build_channel(fields: dict) -> FlpChannel:
|
|
329
|
+
"""Resolve a channel's raw fields: Levels wins over the legacy word/byte
|
|
330
|
+
events; report a default only when the file stored nothing at all. Name
|
|
331
|
+
priority mirrors pyflp's display_name: user rename, else legacy name event,
|
|
332
|
+
else the plugin's internal name."""
|
|
333
|
+
name = fields["name_user"] if "name_user" in fields else fields.get("name_legacy", "")
|
|
334
|
+
name = name or fields.get("name_internal", "")
|
|
335
|
+
volume = fields.get("levels_volume", fields.get("vol_word", fields.get("vol_byte")))
|
|
336
|
+
pan = fields.get("levels_pan", fields.get("pan_word", fields.get("pan_byte")))
|
|
337
|
+
kind = fields.get("kind")
|
|
338
|
+
return FlpChannel(
|
|
339
|
+
index=int(fields["index"]),
|
|
340
|
+
name=str(name),
|
|
341
|
+
volume=int(volume) if volume is not None else VOLUME_DEFAULT,
|
|
342
|
+
pan=int(pan) if pan is not None else PAN_CENTRE,
|
|
343
|
+
pitch_semitones=int(fields.get("levels_pitch", 0)),
|
|
344
|
+
kind=int(kind) if kind is not None else None,
|
|
345
|
+
automation=fields.get("automation", ()),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _version_is_unicode(payload: bytes, offset: int) -> bool:
|
|
350
|
+
"""Decode an FLVersion (ascii) payload; True when >= 11.5, the version at
|
|
351
|
+
which FL switched text events to UTF-16-LE."""
|
|
352
|
+
try:
|
|
353
|
+
text = payload.decode("ascii").rstrip("\0")
|
|
354
|
+
parts = [int(part) for part in text.split(".")]
|
|
355
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
356
|
+
raise FlpError(f"malformed FLVersion event at stream offset {offset}: {exc}") from exc
|
|
357
|
+
return parts[:2] >= [11, 5]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _text(payload: bytes, unicode_text: bool, offset: int) -> str:
|
|
361
|
+
"""Decode one text event payload; NUL-terminated, encoding per FLVersion."""
|
|
362
|
+
encoding = "utf-16-le" if unicode_text else "latin-1"
|
|
363
|
+
try:
|
|
364
|
+
return payload.decode(encoding).rstrip("\0")
|
|
365
|
+
except UnicodeDecodeError as exc:
|
|
366
|
+
raise FlpError(f"undecodable {encoding} text event at stream offset {offset}: {exc}") from exc
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# -- the tool-unit write/read surface (thin shims over the ONE codec engine) ---
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def notes_at(
|
|
373
|
+
path: Path,
|
|
374
|
+
pattern: int,
|
|
375
|
+
channel: int,
|
|
376
|
+
*,
|
|
377
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
378
|
+
) -> list[FlpNote]:
|
|
379
|
+
"""The notes actually in the SAVED file - THE readback for verification."""
|
|
380
|
+
return read(path, event_size_overrides=event_size_overrides).notes_in(pattern, channel)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def write_notes(
|
|
384
|
+
path: Path,
|
|
385
|
+
notes: Sequence[NoteLike],
|
|
386
|
+
*,
|
|
387
|
+
pattern: int,
|
|
388
|
+
channel: int,
|
|
389
|
+
mode: Mode,
|
|
390
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
391
|
+
) -> list[FlpNote]:
|
|
392
|
+
"""Splice the pattern's notes blob; return ``notes_at`` of the RESULT.
|
|
393
|
+
``mode="replace"`` is scoped to the target channel (see formats/notes.py);
|
|
394
|
+
raises FlpError when the readback does not match what was spliced."""
|
|
395
|
+
codec.patch(
|
|
396
|
+
path, NotesFormat(), Target(pattern=pattern, channel=channel), notes, mode,
|
|
397
|
+
event_size_overrides=event_size_overrides,
|
|
398
|
+
)
|
|
399
|
+
return notes_at(path, pattern, channel, event_size_overrides=event_size_overrides)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def write_playlist(
|
|
403
|
+
path: Path,
|
|
404
|
+
clips: Sequence[ClipLike],
|
|
405
|
+
*,
|
|
406
|
+
mode: Mode = "merge",
|
|
407
|
+
arrangement: int = 0,
|
|
408
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
409
|
+
) -> list[FlpPlaylistItem]:
|
|
410
|
+
"""Splice pattern clips into the arrangement's playlist event; return the
|
|
411
|
+
arrangement's decoded playlist AFTER the write. ``mode="replace"``
|
|
412
|
+
replaces the WHOLE arrangement playlist (see formats/playlist.py)."""
|
|
413
|
+
codec.patch(
|
|
414
|
+
path, PlaylistFormat(), Target(arrangement=arrangement), clips, mode,
|
|
415
|
+
event_size_overrides=event_size_overrides,
|
|
416
|
+
)
|
|
417
|
+
project = read(path, event_size_overrides=event_size_overrides)
|
|
418
|
+
return [i for i in project.playlist if i.arrangement == arrangement]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def write_automation(
|
|
422
|
+
path: Path,
|
|
423
|
+
channel: int,
|
|
424
|
+
points: Sequence[AutomationPointLike],
|
|
425
|
+
*,
|
|
426
|
+
mode: str = "replace",
|
|
427
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
428
|
+
) -> int:
|
|
429
|
+
"""Replace the points inside an EXISTING automation channel's blob (see
|
|
430
|
+
formats/automation.py); return the number of points the saved file holds.
|
|
431
|
+
Feeding a channel's decoded points straight back is readback-identical;
|
|
432
|
+
FL-authored blobs are byte-identical in corpus tests."""
|
|
433
|
+
if mode != "replace":
|
|
434
|
+
raise FlpError(f"unsupported mode {mode!r}; write_automation only replaces")
|
|
435
|
+
saved = codec.patch(
|
|
436
|
+
path, AutomationFormat(), Target(channel=channel), points, mode,
|
|
437
|
+
event_size_overrides=event_size_overrides,
|
|
438
|
+
)
|
|
439
|
+
return len(saved)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def effects_at(
|
|
443
|
+
path: Path, insert_index: int, *, event_size_overrides: Mapping[int, int] | None = None
|
|
444
|
+
) -> list[Effect]:
|
|
445
|
+
"""The decoded effect instances in one mixer insert of the saved file."""
|
|
446
|
+
return codec.read(
|
|
447
|
+
path, EffectFormat(), Target(insert=insert_index), event_size_overrides=event_size_overrides
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def add_effect(
|
|
452
|
+
path: Path,
|
|
453
|
+
insert_index: int,
|
|
454
|
+
plugin_name: str,
|
|
455
|
+
*,
|
|
456
|
+
database: PluginDatabase = DEFAULT_PLUGIN_DATABASE,
|
|
457
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
458
|
+
) -> list[Effect]:
|
|
459
|
+
"""Add an FL-authored effect reference, then reparse and verify it.
|
|
460
|
+
|
|
461
|
+
Only the registry's Fruity Parametric EQ 2 reference on Master insert 0,
|
|
462
|
+
empty slot 0 is proven. Unknown names and every other placement refuse
|
|
463
|
+
before the file changes.
|
|
464
|
+
"""
|
|
465
|
+
if insert_index != 0:
|
|
466
|
+
raise FlpError("effect add is only FL-authored for Master insert 0")
|
|
467
|
+
reference = database.reference(plugin_name)
|
|
468
|
+
return codec.patch(
|
|
469
|
+
path, EffectFormat(adding=True), Target(insert=insert_index), [reference], "replace",
|
|
470
|
+
event_size_overrides=event_size_overrides,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def set_channel_levels(
|
|
475
|
+
path: Path,
|
|
476
|
+
channel: int,
|
|
477
|
+
*,
|
|
478
|
+
volume: float | None = None,
|
|
479
|
+
pan: float | None = None,
|
|
480
|
+
pitch_semitones: int | None = None,
|
|
481
|
+
event_size_overrides: Mapping[int, int] | None = None,
|
|
482
|
+
) -> ChannelLevels:
|
|
483
|
+
"""Patch the channel's levels; None leaves a value alone (filled from the
|
|
484
|
+
file's current Levels, so untouched fields rewrite byte-identically).
|
|
485
|
+
Returns the readback. See formats/levels.py for the layout and refusals."""
|
|
486
|
+
fmt, target = LevelsFormat(), Target(channel=channel)
|
|
487
|
+
(current,) = codec.read(path, fmt, target, event_size_overrides=event_size_overrides)
|
|
488
|
+
merged = Levels(
|
|
489
|
+
volume=volume if volume is not None else current.volume,
|
|
490
|
+
pan=pan if pan is not None else current.pan,
|
|
491
|
+
pitch_semitones=pitch_semitones if pitch_semitones is not None else current.pitch_semitones,
|
|
492
|
+
tail=current.tail,
|
|
493
|
+
)
|
|
494
|
+
(saved,) = codec.patch(
|
|
495
|
+
path, fmt, target, [merged], "replace", event_size_overrides=event_size_overrides
|
|
496
|
+
)
|
|
497
|
+
return ChannelLevels(
|
|
498
|
+
channel=channel, volume=saved.volume, pan=saved.pan,
|
|
499
|
+
pitch_semitones=saved.pitch_semitones,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def set_tempo(
|
|
504
|
+
path: Path, bpm: float, *, event_size_overrides: Mapping[int, int] | None = None
|
|
505
|
+
) -> float:
|
|
506
|
+
"""Patch the tempo event in place, or APPEND one at the end of the event
|
|
507
|
+
stream when FL omitted it (see formats/tempo.py). Returns the readback."""
|
|
508
|
+
(stored,) = codec.patch(
|
|
509
|
+
path, TempoFormat(), Target(), [bpm], "replace",
|
|
510
|
+
event_size_overrides=event_size_overrides,
|
|
511
|
+
)
|
|
512
|
+
return stored
|