# 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/>.
"""Contains the types used by tracks and arrangements."""
from __future__ import annotations
import enum
from typing import Any, Iterator, Literal, Optional, cast
import construct as c
import construct_typed as ct
from typing_extensions import TypedDict, Unpack
from pyflp._adapters import FourByteBool, StdEnum
from pyflp._descriptors import EventProp, NestedProp, StructProp
from pyflp._events import (
DATA,
DWORD,
TEXT,
WORD,
AnyEvent,
EventEnum,
EventTree,
ListEventBase,
StructEventBase,
U8Event,
U16Event,
U16TupleEvent,
)
from pyflp._models import (
EventModel,
ItemModel,
ModelCollection,
ModelReprMixin,
supports_slice,
)
from pyflp.channel import Channel, ChannelRack
from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet
from pyflp.pattern import Pattern, Patterns
from pyflp.timemarker import TimeMarker, TimeMarkerID
from pyflp.types import RGBA, FLVersion
__all__ = [
"Arrangements",
"Arrangement",
"Track",
"TrackMotion",
"TrackPress",
"TrackSync",
"ChannelPLItem",
"PatternPLItem",
]
class PLSelectionEvent(StructEventBase):
STRUCT = c.Struct("start" / c.Optional(c.Int32ul), "end" / c.Optional(c.Int32ul)).compile()
class PlaylistEvent(ListEventBase):
STRUCT = c.GreedyRange(
c.Struct(
"position" / c.Int32ul, # 4
"pattern_base" / c.Int16ul * "Always 20480", # 6
"item_index" / c.Int16ul, # 8
"length" / c.Int32ul, # 12
"track_rvidx" / c.Int16ul * "Stored reversed i.e. Track 1 would be 499", # 14
"group" / c.Int16ul, # 16
"_u1" / c.Bytes(2) * "Always (120, 0)", # 18
"item_flags" / c.Int16ul * "Always (64, 0)", # 20
"_u2" / c.Bytes(4) * "Always (64, 100, 128, 128)", # 24
"start_offset" / c.Float32l, # 28
"end_offset" / c.Float32l, # 32
"_u3" / c.If(c.this._params["new"], c.Bytes(28)) * "New in FL 21", # 60
)
)
SIZES = [32, 60]
def __init__(self, id: EventEnum, data: bytes) -> None:
super().__init__(id, data, new=not len(data) % 60)
[docs]@enum.unique
class TrackMotion(ct.EnumBase):
Stay = 0
OneShot = 1
MarchWrap = 2
MarchStay = 3
MarchStop = 4
Random = 5
ExclusiveRandom = 6
[docs]@enum.unique
class TrackPress(ct.EnumBase):
Retrigger = 0
HoldStop = 1
HoldMotion = 2
Latch = 3
[docs]@enum.unique
class TrackSync(ct.EnumBase):
Off = 0
QuarterBeat = 1
HalfBeat = 2
Beat = 3
TwoBeats = 4
FourBeats = 5
Auto = 6
class HeightAdapter(ct.Adapter[float, float, str, str]):
def _decode(self, obj: float, *_: Any) -> str:
return str(int(obj * 100)) + "%"
def _encode(self, obj: str, *_: Any) -> float:
return int(obj[:-1]) / 100
class TrackEvent(StructEventBase):
STRUCT = c.Struct(
"iid" / c.Optional(c.Int32ul), # 4
"color" / c.Optional(c.Int32ul), # 8
"icon" / c.Optional(c.Int32ul), # 12
"enabled" / c.Optional(c.Flag), # 13
"height" / c.Optional(HeightAdapter(c.Float32l)), # 17
"locked_height" / c.Optional(c.Int32sl), # 21
"content_locked" / c.Optional(c.Flag), # 22
"motion" / c.Optional(StdEnum[TrackMotion](c.Int32ul)), # 26
"press" / c.Optional(StdEnum[TrackPress](c.Int32ul)), # 30
"trigger_sync" / c.Optional(StdEnum[TrackSync](c.Int32ul)), # 34
"queued" / c.Optional(FourByteBool), # 38
"tolerant" / c.Optional(FourByteBool), # 42
"position_sync" / c.Optional(StdEnum[TrackSync](c.Int32ul)), # 46
"grouped" / c.Optional(c.Flag), # 47
"locked" / c.Optional(c.Flag), # 48
"_u1" / c.Optional(c.GreedyBytes), # * 66 as of 20.9.1
).compile()
[docs]@enum.unique
class ArrangementsID(EventEnum):
TimeSigNum = (17, U8Event)
TimeSigBeat = (18, U8Event)
Current = (WORD + 36, U16Event)
_LoopPos = (DWORD + 24, U16TupleEvent) #: 1.3.8+
PLSelection = (DATA + 9, PLSelectionEvent)
""".. versionadded:: v2.1.0"""
[docs]@enum.unique
class ArrangementID(EventEnum):
New = (WORD + 35, U16Event)
# _PlaylistItem = DWORD + 1
Name = TEXT + 49
Playlist = (DATA + 25, PlaylistEvent)
[docs]@enum.unique
class TrackID(EventEnum):
Name = TEXT + 47
Data = (DATA + 30, TrackEvent)
[docs]class PLItemBase(ItemModel[PlaylistEvent], ModelReprMixin):
group = StructProp[int]()
"""Returns 0 for no group, else a group number for clips in the same group."""
length = StructProp[int]()
"""PPQ-dependant quantity."""
muted = StructProp[bool]()
"""Whether muted / disabled in the playlist. *New in FL Studio v9.0.0*."""
@property
def offsets(self) -> tuple[float, float]:
"""Returns a ``(start, end)`` offset tuple.
An offset is the distance from the item's actual start or end.
"""
return (self["start_offset"], self["end_offset"])
@offsets.setter
def offsets(self, value: tuple[float, float]) -> None:
self["start_offset"], self["end_offset"] = value
position = StructProp[int]()
"""PPQ-dependant quantity."""
[docs]class ChannelPLItem(PLItemBase, ModelReprMixin):
"""An audio clip or automation on the playlist of an arrangement.
*New in FL Studio v2.0.1*.
"""
@property
def channel(self) -> Channel:
return self._kw["channel"]
@channel.setter
def channel(self, channel: Channel) -> None:
self._kw["channel"] = channel
self["item_index"] = channel.iid
[docs]class PatternPLItem(PLItemBase, ModelReprMixin):
"""A pattern block or clip on the playlist of an arrangement.
*New in FL Studio v7.0.0*.
"""
@property
def pattern(self) -> Pattern:
return self._kw["pattern"]
@pattern.setter
def pattern(self, pattern: Pattern) -> None:
self._kw["pattern"] = pattern
self["item_index"] = pattern.iid + self["pattern_base"]
class _TrackColorProp(StructProp[RGBA]):
def _get(self, ev_or_ins: Any) -> RGBA | None:
value = cast(Optional[int], super()._get(ev_or_ins))
if value is not None:
return RGBA.from_bytes(value.to_bytes(4, "little"))
def _set(self, ev_or_ins: Any, value: RGBA) -> None:
super()._set(ev_or_ins, int.from_bytes(bytes(value), "little")) # type: ignore
class _TrackKW(TypedDict):
items: list[PLItemBase]
[docs]class Track(EventModel, ModelCollection[PLItemBase]):
"""Represents a track in an arrangement on which playlist items are arranged.
![](https://bit.ly/3de6R8y)
"""
def __init__(self, events: EventTree, **kw: Unpack[_TrackKW]) -> None:
super().__init__(events, **kw)
[docs] def __getitem__(self, index: int | slice | str):
if isinstance(index, str):
return NotImplemented
return self._kw["items"][index]
[docs] def __iter__(self) -> Iterator[PLItemBase]:
"""An iterator over :attr:`items`."""
yield from self._kw["items"]
[docs] def __len__(self) -> int:
return len(self._kw["items"])
def __repr__(self) -> str:
return f"Track(name={self.name}, iid={self.iid}, {len(self)} items)"
color = _TrackColorProp(TrackID.Data)
"""Defaults to #485156 (dark slate gray).
![](https://bit.ly/3yVGGuW)
Note:
Unlike :attr:`Channel.color` and :attr:`Insert.color`, values below ``20`` for
any color component (i.e red, green or blue) are NOT ignored by FL Studio.
"""
content_locked = StructProp[bool](TrackID.Data)
""":guilabel:`Lock to content`, defaults to ``False``."""
enabled = StructProp[bool](TrackID.Data)
"""![](https://bit.ly/3eGd91O)"""
grouped = StructProp[bool](TrackID.Data)
"""Whether grouped with the track above (index - 1) or not.
![](https://bit.ly/3yXO5tM)
:guilabel:`&Group with above track`
"""
height = StructProp[str](TrackID.Data)
"""Track height in FL's interface. Linear. :guilabel:`&Size`."""
icon = StructProp[int](TrackID.Data)
"""Returns ``0`` if not set, else an internal icon ID.
![](https://bit.ly/3gln8Kc)
:guilabel:`Change icon`
"""
iid = StructProp[int](TrackID.Data)
"""An integer in the range of 1 to :attr:`Arrangements.max_tracks`."""
locked = StructProp[bool](TrackID.Data)
"""Whether the tracked is in a locked state.
![](https://bit.ly/3VFG6eP)
"""
motion = StructProp[TrackMotion](TrackID.Data)
""":guilabel:`&Performance settings`, defaults to :attr:`TrackMotion.Stay`."""
name = EventProp[str](TrackID.Name)
"""Returns a string or ``None`` if not set."""
position_sync = StructProp[TrackSync](TrackID.Data)
""":guilabel:`&Performance settings`, defaults to :attr:`TrackSync.Off`."""
press = StructProp[TrackPress](TrackID.Data)
""":guilabel:`&Performance settings`, defaults to :attr:`TrackPress.Retrigger`."""
tolerant = StructProp[bool](TrackID.Data)
""":guilabel:`&Performance settings`, defaults to ``True``."""
trigger_sync = StructProp[TrackSync](TrackID.Data)
""":guilabel:`&Performance settings`, defaults to :attr:`TrackSync.FourBeats`."""
queued = StructProp[bool](TrackID.Data)
""":guilabel:`&Performance settings`, defaults to ``False``."""
class _ArrangementKW(TypedDict):
channels: ChannelRack
patterns: Patterns
version: FLVersion
[docs]class Arrangement(EventModel):
"""Contains the timemarkers and tracks in an arrangement.
![](https://bit.ly/3B6is1z)
*New in FL Studio v12.9.1*: Support for multiple arrangements.
"""
def __init__(self, events: EventTree, **kw: Unpack[_ArrangementKW]) -> None:
super().__init__(events, **kw)
def __repr__(self) -> str:
return "Arrangement(iid={}, name={}, {} timemarkers, {} tracks)".format(
self.iid,
repr(self.name),
len(tuple(self.timemarkers)),
len(tuple(self.tracks)),
)
iid = EventProp[int](ArrangementID.New)
"""A 1-based internal index."""
name = EventProp[str](ArrangementID.Name)
"""Name of the arrangement; defaults to **Arrangement**."""
@property
def timemarkers(self) -> Iterator[TimeMarker]:
yield from (TimeMarker(ed) for ed in self.events.group(*TimeMarkerID))
@property
def tracks(self) -> Iterator[Track]:
pl_evt = None
max_idx = 499 if self._kw["version"] >= FLVersion(12, 9, 1) else 198
channels = {channel.iid: channel for channel in self._kw["channels"]}
patterns = {pattern.iid: pattern for pattern in self._kw["patterns"]}
if ArrangementID.Playlist in self.events.ids:
pl_evt = cast(PlaylistEvent, self.events.first(ArrangementID.Playlist))
for track_idx, ed in enumerate(self.events.divide(TrackID.Data, *TrackID)):
if pl_evt is None:
yield Track(ed, items=[])
continue
items: list[PLItemBase] = []
for i, item in enumerate(pl_evt):
if max_idx - item["track_rvidx"] != track_idx:
continue
if item["item_index"] <= item["pattern_base"]:
iid = item["item_index"]
items.append(ChannelPLItem(item, i, pl_evt, channel=channels[iid]))
else:
num = item["item_index"] - item["pattern_base"]
items.append(PatternPLItem(item, i, pl_evt, pattern=patterns[num]))
yield Track(ed, items=items)
# TODO Find whether time is set to signature or division mode.
[docs]class TimeSignature(EventModel, ModelReprMixin):
"""![](https://bit.ly/3EYiMmy)"""
def __str__(self) -> str:
return f"Global time signature: {self.num}/{self.beat}"
num = EventProp[int](ArrangementsID.TimeSigNum)
"""Beats per bar in time division & numerator in time signature mode.
| Min | Max | Default |
|-----|-----|---------|
| 1 | 16 | 4 |
"""
beat = EventProp[int](ArrangementsID.TimeSigBeat)
"""Steps per beat in time division & denominator in time signature mode.
In time signature mode it can be 2, 4, 8 or 16 but in time division mode:
| Min | Max | Default |
|-----|-----|---------|
| 1 | 16 | 4 |
"""
[docs]class Arrangements(EventModel, ModelCollection[Arrangement]):
"""Iterator over arrangements in the project and some related properties."""
def __init__(self, events: EventTree, **kw: Unpack[_ArrangementKW]) -> None:
super().__init__(events, **kw)
[docs] @supports_slice # type: ignore
def __getitem__(self, i: int | str | slice) -> Arrangement:
"""Returns an arrangement based either on its index or name.
Args:
i: The index of the arrangement in which they occur or
:attr:`Arrangement.name` of the arrangement to lookup for or a
slice of indexes.
Raises:
ModelNotFound: An :class:`Arrangement` with the specifed name or
index isn't found.
"""
for idx, arr in enumerate(self):
if (isinstance(i, str) and i == arr.name) or idx == i:
return arr
raise ModelNotFound(i)
# TODO Verify ArrangementsID.Current is the end
# FL changed event ordering a lot, the latest being the most easiest to
# parse; it contains ArrangementID.New event followed by TimeMarker events
# followed by 500 TrackID events. TimeMarkers occured before new arrangement
# event in initial versions of FL20, making them harder to group.
# TODO This logic might not work on older versions of FL.
[docs] def __iter__(self) -> Iterator[Arrangement]:
"""Yields :class:`Arrangement` found in the project.
Raises:
NoModelsFound: When no arrangements are found.
"""
arrnew_occured = False
def select(e: AnyEvent) -> bool | None:
nonlocal arrnew_occured
if e.id == ArrangementID.New:
if arrnew_occured:
return False
arrnew_occured = True
if e.id in (*ArrangementID, *TimeMarkerID, *TrackID):
return True
if e.id == ArrangementsID.Current:
return False # Yield out last arrangement
yield from (Arrangement(ed, **self._kw) for ed in self.events.subtrees(select, len(self)))
[docs] def __len__(self) -> int:
"""The number of arrangements present in the project.
Raises:
NoModelsFound: When no arrangements are found.
"""
if ArrangementID.New not in self.events.ids:
raise NoModelsFound
return self.events.count(ArrangementID.New)
def __repr__(self) -> str:
return f"{len(self)} arrangements"
@property
def current(self) -> Arrangement | None:
"""Currently selected arrangement (via FL's interface).
Raises:
ModelNotFound: When the underlying event value points to an
invalid arrangement index.
"""
if ArrangementsID.Current in self.events.ids:
event = self.events.first(ArrangementsID.Current)
index: int = event.value
try:
return list(self)[index]
except IndexError as exc:
raise ModelNotFound(index) from exc
@property
def loop_pos(self) -> tuple[int, int] | None:
"""Playlist loop start and end points. PPQ dependant.
.. versionchanged:: v2.1.0
:attr:`ArrangementsID.PLSelection` is used by default
while :attr:`ArrangementsID._LoopPos` is a fallback.
*New in FL Studio v1.3.8*.
"""
if ArrangementsID.PLSelection in self.events:
event = cast(PLSelectionEvent, self.events.first(ArrangementsID.PLSelection))
return event["start"], event["end"]
if ArrangementsID._LoopPos in self.events:
return self.events.first(ArrangementsID._LoopPos).value
@loop_pos.setter
def loop_pos(self, value: tuple[int, int]) -> None:
if ArrangementsID.PLSelection in self.events:
event = cast(PLSelectionEvent, self.events.first(ArrangementsID.PLSelection))
event["start"], event["end"] = value
elif ArrangementsID._LoopPos in self.events:
self.events.first(ArrangementsID._LoopPos).value = value
else:
raise PropertyCannotBeSet(ArrangementsID.PLSelection, ArrangementsID._LoopPos)
@property
def max_tracks(self) -> Literal[500, 199]:
return 500 if self._kw["version"] >= FLVersion(12, 9, 1) else 199
time_signature = NestedProp(
TimeSignature, ArrangementsID.TimeSigNum, ArrangementsID.TimeSigBeat
)
"""Project time signature (also used by playlist).
:menuselection:`Options --> &Project general settings --> Time settings`
"""