# PyFLP - An FL Studio project file (.flp) parser
# Copyright (C) 2022 demberto
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
# Public License for more details. You should have received a copy of the
# GNU General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
"""
PyFLP - FL Studio project file parser
=====================================
Load a project file:
>>> import pyflp
>>> project = pyflp.parse("/path/to/parse.flp")
Save the project:
>>> pyflp.save(project, "/path/to/save.flp")
Full docs are available at https://pyflp.rtfd.io.
""" # noqa
from __future__ import annotations
import io
import os
import pathlib
import struct
import sys
import construct as c
from pyflp._events import (
DATA,
DWORD,
NEW_TEXT_IDS,
TEXT,
WORD,
AnyEvent,
AsciiEvent,
EventEnum,
EventTree,
IndexedEvent,
U8Event,
U16Event,
U32Event,
UnicodeEvent,
UnknownDataEvent,
)
from pyflp.exceptions import HeaderCorrupted, VersionNotDetected
from pyflp.plugin import PluginID, get_event_by_internal_name
from pyflp.project import VALID_PPQS, FileFormat, Project, ProjectID
__all__ = ["parse", "save"]
FLP_HEADER = struct.Struct("4sIh2H")
if sys.version_info < (3, 11): # https://github.com/Bobronium/fastenum/issues/2
import fastenum
fastenum.enable() # 33% faster parse()
[docs]def parse(file: pathlib.Path | str) -> Project:
"""Parses an FL Studio project file and returns a parsed :class:`Project`.
Args:
file: Path to the FLP.
Raises:
HeaderCorrupted: When an invalid value is found in the file header.
VersionNotDetected: A correct string type couldn't be determined.
"""
with open(file, "rb") as flp:
stream = io.BytesIO(flp.read())
events: list[AnyEvent] = []
header = stream.read(FLP_HEADER.size)
try:
hdr_magic, hdr_size, fmt, channel_count, ppq = FLP_HEADER.unpack(header)
except struct.error as exc:
raise HeaderCorrupted("Couldn't read the header entirely") from exc
if hdr_magic != b"FLhd":
raise HeaderCorrupted("Unexpected header chunk magic; expected 'FLhd'")
if hdr_size != 6:
raise HeaderCorrupted("Unexpected header chunk size; expected 6")
try:
file_format = FileFormat(fmt)
except ValueError as exc:
raise HeaderCorrupted("Unsupported project file format") from exc
if ppq not in VALID_PPQS:
raise HeaderCorrupted("Invalid PPQ")
if stream.read(4) != b"FLdt":
raise HeaderCorrupted("Unexpected data chunk magic; expected 'FLdt'")
events_size = int.from_bytes(stream.read(4), "little")
if not events_size: # pragma: no cover
raise HeaderCorrupted("Data chunk size couldn't be read")
stream.seek(0, os.SEEK_END)
file_size = stream.tell()
if file_size != events_size + 22:
raise HeaderCorrupted("Data chunk size corrupted")
plug_name = None
str_type: type[AsciiEvent] | type[UnicodeEvent] | None = None
stream.seek(22) # Back to start of events
while stream.tell() < file_size:
event_type: type[AnyEvent] | None = None
id = EventEnum(int.from_bytes(stream.read(1), "little"))
if id < WORD:
value = stream.read(1)
elif id < DWORD:
value = stream.read(2)
elif id < TEXT:
value = stream.read(4)
else:
size = c.VarInt.parse_stream(stream)
value = stream.read(size)
if id == ProjectID.FLVersion:
parts = value.decode("ascii").rstrip("\0").split(".")
if [int(part) for part in parts][0:2] >= [11, 5]:
str_type = UnicodeEvent
else:
str_type = AsciiEvent
for enum_ in EventEnum.__subclasses__():
if id in enum_:
event_type = getattr(enum_(id), "type")
break
if event_type is None:
if id < WORD:
event_type = U8Event
elif id < DWORD:
event_type = U16Event
elif id < TEXT:
event_type = U32Event
elif id < DATA or id.value in NEW_TEXT_IDS:
if str_type is None: # pragma: no cover
raise VersionNotDetected # ! This should never happen
event_type = str_type
if id == PluginID.InternalName:
plug_name = event_type(id, value).value
elif id == PluginID.Data and plug_name is not None:
event_type = get_event_by_internal_name(plug_name)
else:
event_type = UnknownDataEvent
events.append(event_type(id, value))
return Project(
EventTree(init=(IndexedEvent(r, e) for r, e in enumerate(events))),
channel_count=channel_count,
format=file_format,
ppq=ppq,
)
[docs]def save(project: Project, file: pathlib.Path | str) -> None:
"""Save a parsed project back into a file.
Caution:
Always have a backup ready, just in case π
Args:
project: The object returned by :meth:`parse`.
file: The file in which the contents of :attr:`project` are serialised back.
"""
buf = bytearray()
num_channels = len(project.channels)
header = FLP_HEADER.pack(b"FLhd", 6, project.format, num_channels, project.ppq)
buf.extend(header)
buf.extend(b"FLdt" + (b"\0" * 4))
total_size = 0
for event in project.events:
raw = bytes(event)
total_size += len(raw)
buf.extend(raw)
buf[18:22] = total_size.to_bytes(4, "little")
with open(file, "wb") as fp:
fp.write(buf)