Source code for pyflp.mixer

# 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 the mixer, inserts and effect slots."""

from __future__ import annotations

import dataclasses
import enum
from collections import defaultdict
from typing import Any, DefaultDict, Iterator, NamedTuple, cast

import construct as c
import construct_typed as ct
from typing_extensions import NotRequired, TypedDict, Unpack

from pyflp._adapters import StdEnum
from pyflp._descriptors import EventProp, FlagProp, NamedPropMixin, ROProperty, RWProperty
from pyflp._events import (
    DATA,
    DWORD,
    TEXT,
    WORD,
    AnyEvent,
    ColorEvent,
    EventEnum,
    EventTree,
    I16Event,
    I32Event,
    ListEventBase,
    StructEventBase,
    U16Event,
)
from pyflp._models import EventModel, ModelBase, ModelCollection, ModelReprMixin, supports_slice
from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet
from pyflp.plugin import (
    FruityBalance,
    FruityBloodOverdrive,
    FruityCenter,
    FruityFastDist,
    FruityNotebook2,
    FruitySend,
    FruitySoftClipper,
    FruityStereoEnhancer,
    PluginID,
    PluginProp,
    Soundgoodizer,
    VSTPlugin,
)
from pyflp.types import RGBA, FLVersion, T

__all__ = ["Insert", "InsertDock", "InsertEQ", "InsertEQBand", "Mixer", "Slot"]


@enum.unique
class _InsertFlags(enum.IntFlag):
    None_ = 0
    PolarityReversed = 1 << 0
    SwapLeftRight = 1 << 1
    EnableEffects = 1 << 2
    Enabled = 1 << 3
    DisableThreadedProcessing = 1 << 4
    U5 = 1 << 5
    DockMiddle = 1 << 6
    DockRight = 1 << 7
    U8 = 1 << 8
    U9 = 1 << 9
    SeparatorShown = 1 << 10
    Locked = 1 << 11
    Solo = 1 << 12
    U13 = 1 << 13
    U14 = 1 << 14
    AudioTrack = 1 << 15  # Whether insert is linked to an audio track


@enum.unique
class _MixerParamsID(ct.EnumBase):
    SlotEnabled = 0
    SlotMix = 1
    RouteVolStart = 64  # 64 - 191 are send level events
    Volume = 192
    Pan = 193
    StereoSeparation = 194
    LowGain = 208
    MidGain = 209
    HighGain = 210
    LowFreq = 216
    MidFreq = 217
    HighFreq = 218
    LowQ = 224
    MidQ = 225
    HighQ = 226


class InsertFlagsEvent(StructEventBase):
    STRUCT = c.Struct(
        "_u1" / c.Optional(c.Bytes(4)),  # 4
        "flags" / c.Optional(StdEnum[_InsertFlags](c.Int32ul)),  # 8
        "_u2" / c.Optional(c.Bytes(4)),  # 12
    ).compile()


class InsertRoutingEvent(ListEventBase):
    STRUCT = c.GreedyRange(c.Flag)


@dataclasses.dataclass
class _InsertItems:
    slots: DefaultDict[int, dict[int, dict[str, Any]]] = dataclasses.field(
        default_factory=lambda: defaultdict(dict)
    )
    own: dict[int, dict[str, Any]] = dataclasses.field(default_factory=dict)


class MixerParamsEvent(ListEventBase):
    STRUCT = c.GreedyRange(
        c.Struct(
            "_u4" / c.Bytes(4),  # 4
            "id" / StdEnum[_MixerParamsID](c.Byte),  # 5
            "_u1" / c.Byte,  # 6
            "channel_data" / c.Int16ul,  # 8
            "msg" / c.Int32sl,  # 12
        )
    )

    def __init__(self, id: Any, data: bytearray) -> None:
        super().__init__(id, data)
        self.items_: DefaultDict[int, _InsertItems] = defaultdict(_InsertItems)

        for item in self.data:
            insert_idx = (item["channel_data"] >> 6) & 0x7F
            slot_idx = item["channel_data"] & 0x3F
            insert = self.items_[insert_idx]
            id = item["id"]

            if id in (_MixerParamsID.SlotEnabled, _MixerParamsID.SlotMix):
                insert.slots[slot_idx][id] = item
            else:
                insert.own[id] = item


[docs]@enum.unique class InsertID(EventEnum): Icon = (WORD + 31, I16Event) Output = (DWORD + 19, I32Event) Color = (DWORD + 21, ColorEvent) #: 4.0+ Input = (DWORD + 26, I32Event) Name = TEXT + 12 #: 3.5.4+ Routing = (DATA + 27, InsertRoutingEvent) Flags = (DATA + 28, InsertFlagsEvent)
[docs]@enum.unique class MixerID(EventEnum): APDC = 29 Params = (DATA + 17, MixerParamsEvent)
[docs]@enum.unique class SlotID(EventEnum): Index = (WORD + 34, U16Event)
# ? Maybe added in FL Studio v6.0.1
[docs]class InsertDock(enum.Enum): """![](https://bit.ly/3eLum9D) See Also: :attr:`Insert.dock` """ # noqa Left = enum.auto() Middle = enum.auto() Right = enum.auto()
class _InsertEQBandKW(TypedDict, total=False): gain: dict[str, Any] freq: dict[str, Any] reso: dict[str, Any] class _InsertEQBandProp(NamedPropMixin, RWProperty[int]): def __get__(self, ins: InsertEQBand, owner: Any = None) -> int | None: if owner is None: return NotImplemented return ins._kw[self._prop]["msg"] def __set__(self, ins: InsertEQBand, value: int) -> None: ins._kw[self._prop]["msg"] = value
[docs]class InsertEQBand(ModelBase, ModelReprMixin): def __init__(self, **kw: Unpack[_InsertEQBandKW]) -> None: super().__init__(**kw) @property def size(self) -> int: return 12 * len(self._kw) # ! TODO gain = _InsertEQBandProp() """ | Min | Max | Default | |-------|------|---------| | -1800 | 1800 | 0 | """ freq = _InsertEQBandProp() """Nonlinear. Default depends on band e.g. ``InsertEQ.low``. | Type | Value | Representation | |------|-------|----------------| | Min | 0 | 10 Hz | | Max | 65536 | 16 kHz | """ reso = _InsertEQBandProp() """ | Min | Max | Default | |-----|-------|---------| | 0 | 65536 | 17500 | """
class _InsertEQPropArgs(NamedTuple): freq: int gain: int reso: int class _InsertEQProp(NamedPropMixin, ROProperty[InsertEQBand]): def __init__(self, ids: _InsertEQPropArgs) -> None: super().__init__() self._ids = ids def __get__(self, ins: InsertEQ, owner: Any = None) -> InsertEQBand: if owner is None: return NotImplemented items: _InsertEQBandKW = {} for id, param in cast(_InsertItems, ins._kw["params"]).own.items(): if id == self._ids.freq: items["freq"] = param elif id == self._ids.gain: items["gain"] = param elif id == self._ids.reso: items["reso"] = param return InsertEQBand(**items) # Stored in MixerID.Params event.
[docs]class InsertEQ(ModelBase, ModelReprMixin): """Post-effect :class:`Insert` EQ with 3 adjustable bands. ![](https://bit.ly/3RUCQt6) See Also: :attr:`Insert.eq` """ def __init__(self, params: _InsertItems) -> None: super().__init__(params=params) @property def size(self) -> int: return 12 * self._kw["param"] # ! TODO low = _InsertEQProp( _InsertEQPropArgs(_MixerParamsID.LowFreq, _MixerParamsID.LowGain, _MixerParamsID.LowQ) ) """Low shelf band. Default frequency - 5777 (90 Hz).""" mid = _InsertEQProp( _InsertEQPropArgs(_MixerParamsID.MidFreq, _MixerParamsID.MidGain, _MixerParamsID.MidQ) ) """Middle band. Default frequency - 33145 (1500 Hz).""" high = _InsertEQProp( _InsertEQPropArgs(_MixerParamsID.HighFreq, _MixerParamsID.HighGain, _MixerParamsID.HighQ) ) """High shelf band. Default frequency - 55825 (8000 Hz)."""
class _MixerParamProp(RWProperty[T]): def __init__(self, id: int) -> None: self._id = id def __get__(self, ins: Insert, owner: object = None) -> T | None: if owner is None: return NotImplemented for id, item in cast(_InsertItems, ins._kw["params"]).own.items(): if id == self._id: return item["msg"] def __set__(self, ins: Insert, value: T) -> None: for id, item in cast(_InsertItems, ins._kw["params"]).own.items(): if id == self._id: item["msg"] = value return raise PropertyCannotBeSet(self._id)
[docs]class Slot(EventModel): """Represents an effect slot in an `Insert` / mixer channel. ![](https://bit.ly/3RUDtTu) """ def __init__(self, events: EventTree, params: list[dict[str, Any]] | None = None) -> None: super().__init__(events, params=params or []) def __repr__(self) -> str: return f"Slot (name={self.name}, iid={self.index}, plugin={self.plugin!r})" color = EventProp[RGBA](PluginID.Color) # TODO controllers = KWProp[List[RemoteController]]() iid = EventProp[int](SlotID.Index) """A 0-based internal index.""" internal_name = EventProp[str](PluginID.InternalName) """'Fruity Wrapper' for VST/AU plugins or factory name for native plugins.""" enabled = _MixerParamProp[bool](_MixerParamsID.SlotEnabled) """![](https://bit.ly/3eN4Ile)""" icon = EventProp[int](PluginID.Icon) index = EventProp[int](SlotID.Index) mix = _MixerParamProp[int](_MixerParamsID.SlotMix) """Dry/Wet mix. Defaults to maximum value. | Type | Value | Representation | |---------|-------|----------------| | Min | -6400 | 100% left | | Max | 6400 | 100% right | | Default | 0 | Centred | """ name = EventProp[str](PluginID.Name) plugin = PluginProp( VSTPlugin, FruityBalance, FruityBloodOverdrive, FruityCenter, FruityFastDist, FruityNotebook2, FruitySend, FruitySoftClipper, FruityStereoEnhancer, Soundgoodizer, ) """The effect loaded into the slot."""
class _InsertKW(TypedDict): iid: int max_slots: int params: NotRequired[_InsertItems] # TODO Need to make a `load()` method which will be able to parse preset files # (by looking at Project.format) and use `MixerParameterEvent.items` to get # remaining data. Normally, the `Mixer` passes this information to the Inserts # (and Inserts to the `Slot`s directly).
[docs]class Insert(EventModel, ModelCollection[Slot]): """Represents a mixer track to which channel from the rack are routed to. ![](https://bit.ly/3LeGKuN) """ def __init__(self, events: EventTree, **kw: Unpack[_InsertKW]) -> None: super().__init__(events, **kw) # TODO Add number of used slots def __repr__(self) -> str: return f"Insert(name={self.name!r}, iid={self.iid})"
[docs] @supports_slice # type: ignore def __getitem__(self, i: int | str) -> Slot: """Returns an effect slot of the specified index or name. Args: i: An index in the range of 0 to :attr:`Mixer.max_slots` or the name of the :class:`Slot`. Raises: ModelNotFound: An effect :class:`Slot` with the specified index or name isn't found. """ for idx, slot in enumerate(self): if (isinstance(i, int) and idx == i) or i == slot.name: return slot raise ModelNotFound(i)
@property def iid(self) -> int: """-1 for "current" insert, 0 for master and upto :attr:`Mixer.max_inserts`.""" return self._kw["iid"]
[docs] def __iter__(self) -> Iterator[Slot]: """Iterator over the effect empty and used slots.""" for idx, ed in enumerate(self.events.divide(SlotID.Index, *SlotID, *PluginID)): yield Slot(ed, params=self._kw["params"].slots[idx])
[docs] def __len__(self) -> int: try: return self.events.count(SlotID.Index) except KeyError: return len(list(self))
bypassed = FlagProp(_InsertFlags.EnableEffects, InsertID.Flags, inverted=True) """Whether all slots are bypassed.""" channels_swapped = FlagProp(_InsertFlags.SwapLeftRight, InsertID.Flags) """Whether the left and right channels are swapped.""" color = EventProp[RGBA](InsertID.Color) """Defaults to #636C71 (granite gray) in FL Studio. ![](https://bit.ly/3yVKXPc) Values below 20 for any color component (R, G, B) are ignored by FL. *New in FL Studio v4.0*. """ @property def dock(self) -> InsertDock | None: """The position (left, middle or right) where insert is docked in mixer. :menuselection:`Insert --> Layout --> Dock to` ![](https://bit.ly/3eLum9D) """ try: event = cast(InsertFlagsEvent, self.events.first(InsertID.Flags)) except KeyError: return None flags = _InsertFlags(event["flags"]) if _InsertFlags.DockMiddle in flags: return InsertDock.Middle if _InsertFlags.DockRight in flags: return InsertDock.Right return InsertDock.Left enabled = FlagProp(_InsertFlags.Enabled, InsertID.Flags) """Whether an insert in the mixer is enabled or disabled. ![](https://bit.ly/3BoRBOj) """ @property def eq(self) -> InsertEQ: """3-band post EQ. ![](https://bit.ly/3RUCQt6) """ return InsertEQ(self._kw["params"]) icon = EventProp[int](InsertID.Icon) """Internal ID of the icon shown beside ``name``. ![](https://bit.ly/3Slr6jc) """ input = EventProp[int](InsertID.Input) """![](https://bit.ly/3RO0ckC)""" is_solo = FlagProp(_InsertFlags.Solo, InsertID.Flags) """Whether the insert is solo'd.""" locked = FlagProp(_InsertFlags.Locked, InsertID.Flags) """Whether an insert in the mixer is in locked state. ![](https://bit.ly/3SdPbc2) """ name = EventProp[str](InsertID.Name) """*New in FL Studio v3.5.4*.""" output = EventProp[int](InsertID.Output) """![](https://bit.ly/3LjWjBD)""" pan = _MixerParamProp[int](_MixerParamsID.Pan) """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -6400 | 100% left | | Max | 6400 | 100% right | | Default | 0 | Centred | ![](https://bit.ly/3DsZRj4) """ polarity_reversed = FlagProp(_InsertFlags.PolarityReversed, InsertID.Flags) """Whether phase / polarity is reversed / inverted.""" @property def routes(self) -> Iterator[int]: """Send volumes to routed inserts. *New in FL Studio v4.0*. """ items = iter(cast(InsertRoutingEvent, self.events.first(InsertID.Routing))) for id, item in cast(_InsertItems, self._kw["params"]).own.items(): if id >= _MixerParamsID.RouteVolStart: try: cond = next(items) except StopIteration: continue else: if cond: yield item["msg"] separator_shown = FlagProp(_InsertFlags.SeparatorShown, InsertID.Flags) """Whether separator is shown before the insert. :menuselection:`Insert --> Group --> Separator` """ stereo_separation = _MixerParamProp[int](_MixerParamsID.StereoSeparation) """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 64 | 100% merged | | Max | -64 | 100% separated | | Default | 0 | No effect | """ volume = _MixerParamProp[int](_MixerParamsID.Volume) """Post volume fader. Logarithmic. | Type | Value | Representation | |---------|-------|---------------------| | Min | 0 | 0% / -INFdB / 0.00 | | Max | 16000 | 125% / 5.6dB / 1.90 | | Default | 12800 | 100% / 0.0dB / 1.00 | """
class _MixerKW(TypedDict): version: FLVersion # TODO FL Studio version in which slots were increased to 10 # TODO A move() method to change the placement of Inserts; it's difficult!
[docs]class Mixer(EventModel, ModelCollection[Insert]): """Represents the mixer which contains :class:`Insert` instances. ![](https://bit.ly/3eOsblF) """ _MAX_INSERTS = { (1, 6, 5): 5, (2, 0, 1): 8, (3, 0, 0): 18, (3, 3, 0): 20, (4, 0, 0): 64, (9, 0, 0): 105, (12, 9, 0): 127, } _MAX_SLOTS = {(1, 6, 5): 4, (3, 0, 0): 8} def __init__(self, events: EventTree, **kw: Unpack[_MixerKW]) -> None: super().__init__(events, **kw) # Inserts don't store their index internally.
[docs] @supports_slice # type: ignore def __getitem__(self, i: int | str | slice) -> Insert: """Returns an insert with the specified index or name. Args: i: An index between 0 to :attr:`Mixer.max_inserts` resembling the one shown by FL Studio or the name of the insert. Use 0 for master and -1 for "current" insert. Raises: ModelNotFound: An :class:`Insert` with the specifcied name or index isn't found. """ for idx, insert in enumerate(self): if (isinstance(i, int) and idx == i + 1) or i == insert.name: return insert raise ModelNotFound(i)
[docs] def __iter__(self) -> Iterator[Insert]: def select(e: AnyEvent) -> bool | None: if e.id == InsertID.Output: return False if e.id in (*InsertID, *PluginID, *SlotID): return True params: dict[int, _InsertItems] = {} if MixerID.Params in self.events.ids: params = cast(MixerParamsEvent, self.events.first(MixerID.Params)).items_ for i, ed in enumerate(self.events.subtrees(select, self.max_inserts)): if i in params: yield Insert(ed, iid=i - 1, max_slots=self.max_slots, params=params[i]) else: yield Insert(ed, iid=i - 1, max_slots=self.max_slots)
[docs] def __len__(self) -> int: """Returns the number of inserts present in the project. Raises: NoModelsFound: No inserts could be found. """ if InsertID.Flags not in self.events.ids: raise NoModelsFound return self.events.count(InsertID.Flags)
def __str__(self) -> str: return f"Mixer: {len(self)} inserts" apdc = EventProp[bool](MixerID.APDC) """Whether automatic plugin delay compensation is enabled for the inserts.""" @property def max_inserts(self) -> int: """Estimated max number of inserts including sends, master and current. Maximum number of slots w.r.t. FL Studio: * 1.6.5: 4 inserts + master, 5 in total * 2.0.1: 8 * 3.0.0: 16 inserts, 2 sends. * 3.3.0: +2 sends. * 4.0.0: 64 * 9.0.0: 99 inserts, 105 in total. * 12.9.0: 125 + master + current. """ version = dataclasses.astuple(self._kw["version"]) for k, v in self._MAX_INSERTS.items(): if version <= k: return v return 127 @property def max_slots(self) -> int: """Estimated max number of effect slots per insert. Maximum number of slots w.r.t. FL Studio: * 1.6.5: 4 * 3.3.0: 8 """ version = dataclasses.astuple(self._kw["version"]) for k, v in self._MAX_SLOTS.items(): if version <= k: return v return 10