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 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