From e099202f79f825cca9f776cdba348c5a43d23e6a Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Fri, 1 Dec 2023 23:49:20 +1100 Subject: [PATCH 01/15] Remove subclasses of Integration --- src/integrations/__init__.py | 7 +- src/integrations/integration.py | 141 ++------------------------------ 2 files changed, 8 insertions(+), 140 deletions(-) diff --git a/src/integrations/__init__.py b/src/integrations/__init__.py index e22eb180..85757d01 100644 --- a/src/integrations/__init__.py +++ b/src/integrations/__init__.py @@ -23,12 +23,7 @@ WheelStrategy, ) -from .integration import ( - Integration, - CoreIntegration, - PluginIntegration, - WindowIntegration, -) +from .integration import Integration from .pager import IntegrationPager # Register all integrations diff --git a/src/integrations/integration.py b/src/integrations/integration.py index daa586f5..2ee01e56 100644 --- a/src/integrations/integration.py +++ b/src/integrations/integration.py @@ -13,11 +13,9 @@ from typing import final from common import log, verbosity -from common.util.abstract_method_error import AbstractMethodError -from common.plug_indexes import WindowIndex, FlIndex +from common.plug_indexes import FlIndex from control_surfaces import ControlEvent from devices import DeviceShadow -from abc import abstractmethod class Integration: @@ -80,17 +78,6 @@ def apply(self, thorough: bool) -> None: """ self._shadow.apply(thorough) - @classmethod - @abstractmethod - def create(cls, shadow: DeviceShadow) -> 'Integration': - """ - Create and return an instance of this integration - - NOTE: On release of Python 3.11, upgrade to `Self` type and remove - redefinitions in abstract subclasses - """ - raise AbstractMethodError(cls) - def processEvent(self, mapping: ControlEvent, index: FlIndex) -> bool: """ Process a MIDI event that has been sent to this integration. @@ -140,128 +127,14 @@ def tick(self, index: FlIndex) -> None: * `index` (`FlIndex`): index of active plugin or window """ - -class PluginIntegration(Integration): - """ - Plugin integration, used to represent VSTs and FL Studio generators and - effects. - - ## Methods to implement: - - * `@classmethod getPlugIds(cls) -> tuple[str, ...]` return a tuple of all - the plugin names that this integration can handle. - - * `@classmethod create(cls, shadow: DeviceShadow) -> Self` create an - instance of this integration. This is used so that we can ensure type - safety of the constructor when plugins are instantiated. - """ - - @classmethod - @abstractmethod - def getPlugIds(cls) -> tuple[str, ...]: - """ - Returns the names of the plugins this class should be associated with. - - Used to identify and map this integration to the plugin - - ### Returns: - * `tuple[str, ...]`: plugin names - """ - raise AbstractMethodError() - - @classmethod - @abstractmethod - def create(cls, shadow: DeviceShadow) -> 'PluginIntegration': - """ - Create and return an instance of this integration - - This method should be implemented by every plugin definition - """ - raise AbstractMethodError(cls) - - -class WindowIntegration(Integration): - """ - FL Studio window integrations - - * Mixer - - * Channel rack - - * Playlist - - * Piano roll - - * Browser - - Methods to implement: - * `@classmethod getWindowId(cls) -> WindowIndex` return a the window index - that this plugin should be active for. - - * `@classmethod create(cls, shadow: DeviceShadow) -> Self` create an - instance of this plugin. This is used so that we can ensure type safety - of the constructor when plugins are instantiated. - """ - - @classmethod - @abstractmethod - def getWindowId(cls) -> WindowIndex: - """ - Returns the ID of the window this class should be associated with. - - Used to identify and map to the plugin - - ### Returns: - * `int`: window ID - """ - raise AbstractMethodError() - - @classmethod - @abstractmethod - def create(cls, shadow: DeviceShadow) -> 'WindowIntegration': - """ - Create and return an instance of this plugin - - This method should be implemented by every plugin definition + def isEnabled(self) -> bool: """ - raise AbstractMethodError(cls) - - -class CoreIntegration(Integration): - """ - Core integrations, representing integrations that are active independent of - a VST, FL Studio plugin or window. + Return whether the integration should be active - Methods to implement: - * `@classmethod shouldBeActive(cls) -> bool` return whether the integration - should currently be active. - - * `@classmethod create(cls, shadow: DeviceShadow) -> Self` create an - instance of this integration. This is used so that we can ensure type - safety of the constructor when integrations are instantiated, although - imo this is a yucky solution, which I'm planning to fix to close #151 - """ - - @classmethod - @abstractmethod - def shouldBeActive(cls) -> bool: - """ - Returns whether this integration is currently active, meaning it is - open to processing events, and will impact the overall device state - - If the integration should be active, this should return `True`. + By default, this returns `True`, but if the integration has nothing to + do, returning `False` can save some processing time ### Returns: - * `bool`: whether the integration should be active - """ - raise AbstractMethodError(cls) - - @classmethod - @abstractmethod - def create(cls, shadow: DeviceShadow) -> 'CoreIntegration': - """ - Create and return an instance of this integration - - This method should be implemented by every integration definition + * `bool`: whether the integration should be enabled """ - raise AbstractMethodError(cls) + return True From 1645efc5abc61d69f6697c025cfd308e46445f0a Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 00:40:22 +1100 Subject: [PATCH 02/15] Rewrite device registration code --- src/common/extensions/__init__.py | 16 +++++ src/common/extensions/devices.py | 101 ++++++++++++++++++++++++++++++ src/common/types/decorator.py | 43 +++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/common/extensions/__init__.py create mode 100644 src/common/extensions/devices.py create mode 100644 src/common/types/decorator.py diff --git a/src/common/extensions/__init__.py b/src/common/extensions/__init__.py new file mode 100644 index 00000000..2ac1a50e --- /dev/null +++ b/src/common/extensions/__init__.py @@ -0,0 +1,16 @@ +""" +common > extensions + +Code responsible for managing script integrations and device definitions. + +* Allows for devices and integrations to be registered alongside their criteria + for usage. +* Allows for the script core to access these integrations in order to integrate + the script with devices and FL Studio + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" diff --git a/src/common/extensions/devices.py b/src/common/extensions/devices.py new file mode 100644 index 00000000..981576f8 --- /dev/null +++ b/src/common/extensions/devices.py @@ -0,0 +1,101 @@ +""" +common > extensions > devices + +Code responsible for registering and managing devices + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" +from typing import TYPE_CHECKING, Optional +from common.types.decorator import Decorator + +if TYPE_CHECKING: + from devices import Device + + +_devices: dict[bytes, Device] = {} + + +def get_device_matching_id(device_id: bytes) -> Optional[type[Device]]: + """ + Return a device definition that matches the given device ID + + Check is performed against the minimum number of bytes between the given + device ID and the device IDs of registered device definitions + + ### Args: + * `device_id` (`bytes`): device ID to match against + + ### Returns: + * `Optional[type[Device]]`: device, if found + """ + for check_id, device in _devices.items(): + for byte_a, byte_b in zip(device_id, check_id): + if byte_a != byte_b: + break + else: + # Reached the end of the loop without hitting `break`, therefore + # this device matches + return device + + # None of the devices matched + return None + + +def register(device_id: bytes) -> Decorator[type[Device], type[Device]]: + """ + Register a device definition to be associated with the given `device_id` + + This device definition will be used when a device is detected that matches + the given `device_id`, meaning that when iterating over the given bytes, + all bytes of the connected device's ID are equal. + + ### Usage + + ```py + @devices.register(bytes([ + 0xF0, 0x7E, 0x00, 0x06, 0x02, 0x00, 0x20, 0x29, 0x01, 0x01, 0x00, 0x00 + ])) + class MyDevice(Device): + ... + ``` + + ### Args: + * `device_id` (`bytes`): device ID to match against + + ### Returns: + * `Decorator[Device]`: _description_ + """ + def inner(device_definition: type[Device]) -> type[Device]: + # Do the error checking inside `inner` so we can give a nicer error + # message, and to make sure people don't abuse decorators to register + # multiple devices with one outer `register` call + if (found_dev := get_device_matching_id(device_id)) is not None: + raise ValueError( + f"A device matching device_id {device_id} has already been " + f"registered.\n\n" + f"Attempted to register: {device_definition}\n" + f"Previously registered device: {found_dev}" + ) + + _devices[device_id] = device_definition + return device_definition + + return inner + + +def get_registered_devices() -> dict[bytes, Device]: + """ + Returns a dictionary mapping registered device IDs to the corresponding + device definitions + + Will be used by the meta code generator to create a script file for all + supported devices + + ### Returns: + * `dict[bytes, Device]`: device definition mapping + """ + return _devices diff --git a/src/common/types/decorator.py b/src/common/types/decorator.py new file mode 100644 index 00000000..14ccc7bf --- /dev/null +++ b/src/common/types/decorator.py @@ -0,0 +1,43 @@ +from typing import Protocol, TypeVar, Generic + + +TIn = TypeVar('TIn', contravariant=True) +TOut = TypeVar('TOut', covariant=True) + + +class Decorator(Protocol, Generic[TIn, TOut]): + """ + Represents a decorated value, used to simplify type definitions. + + ### Usage + + For functions that return a decorator function, annotate the return type as + `Decorator[InputType, OutputType]` + + ### Example + + If we have the following type alias: + + ```py + IntFunction = Callable[[int, int], int] + ``` + + And the following function that matches its definition: + + ```py + def add(a: int, b: int) -> int: + return a + b + ``` + + We can type a decorator function as follows: + + ```py + def register_operator(op: str) -> Decorator[IntFunction, IntFunction]: + def inner(value: IntFunction) -> IntFunction: + # register the function or whatever + return value + return inner + ``` + """ + def __call__(self, value: TIn) -> TOut: + ... From f2b53f228f5068273ac2a44f69a85856e5fa4806 Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 00:54:05 +1100 Subject: [PATCH 03/15] Improve docstring template --- resources/docstring_template.mustache | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/resources/docstring_template.mustache b/resources/docstring_template.mustache index b43ac902..872a924e 100644 --- a/resources/docstring_template.mustache +++ b/resources/docstring_template.mustache @@ -7,7 +7,7 @@ {{extendedSummaryPlaceholder}} {{#parametersExist}} -### Args: +### Args {{#args}} * `{{var}}` (`{{typePlaceholder}}`): {{descriptionPlaceholder}} @@ -18,16 +18,8 @@ {{/kwargs}} {{/parametersExist}} -{{#exceptionsExist}} -### Raises: -{{#exceptions}} -* `{{type}}`: {{descriptionPlaceholder}} - -{{/exceptions}} -{{/exceptionsExist}} - {{#returnsExist}} -### Returns: +### Returns {{#returns}} * `{{typePlaceholder}}`: {{descriptionPlaceholder}} @@ -35,7 +27,7 @@ {{/returnsExist}} {{#yieldsExist}} -### Yields: +### Yields {{#yields}} * `{{typePlaceholder}}`: {{descriptionPlaceholder}} From 63bea5ba39db744ae053c61b7df2761590c3288f Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 00:54:24 +1100 Subject: [PATCH 04/15] Use quotes to fix bad imports --- src/common/extensions/devices.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/common/extensions/devices.py b/src/common/extensions/devices.py index 981576f8..799af658 100644 --- a/src/common/extensions/devices.py +++ b/src/common/extensions/devices.py @@ -16,10 +16,10 @@ from devices import Device -_devices: dict[bytes, Device] = {} +_devices: dict[bytes, 'Device'] = {} -def get_device_matching_id(device_id: bytes) -> Optional[type[Device]]: +def get_device_matching_id(device_id: bytes) -> Optional[type['Device']]: """ Return a device definition that matches the given device ID @@ -45,7 +45,7 @@ def get_device_matching_id(device_id: bytes) -> Optional[type[Device]]: return None -def register(device_id: bytes) -> Decorator[type[Device], type[Device]]: +def register(device_id: bytes) -> Decorator[type['Device'], type['Device']]: """ Register a device definition to be associated with the given `device_id` @@ -63,13 +63,13 @@ class MyDevice(Device): ... ``` - ### Args: + ### Args * `device_id` (`bytes`): device ID to match against - ### Returns: + ### Returns * `Decorator[Device]`: _description_ """ - def inner(device_definition: type[Device]) -> type[Device]: + def inner(device_definition: type['Device']) -> type['Device']: # Do the error checking inside `inner` so we can give a nicer error # message, and to make sure people don't abuse decorators to register # multiple devices with one outer `register` call @@ -87,7 +87,7 @@ def inner(device_definition: type[Device]) -> type[Device]: return inner -def get_registered_devices() -> dict[bytes, Device]: +def get_registered_devices() -> dict[bytes, 'Device']: """ Returns a dictionary mapping registered device IDs to the corresponding device definitions From 3cabc33dbf1700b1e7cc0529fed999b48625f597 Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 01:21:10 +1100 Subject: [PATCH 05/15] Add code for registering integrations --- src/common/extensions/__init__.py | 4 + src/common/extensions/integrations.py | 192 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/common/extensions/integrations.py diff --git a/src/common/extensions/__init__.py b/src/common/extensions/__init__.py index 2ac1a50e..0b287a3d 100644 --- a/src/common/extensions/__init__.py +++ b/src/common/extensions/__init__.py @@ -14,3 +14,7 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ +from . import devices, integrations + + +__all__ = ['devices', 'integrations'] diff --git a/src/common/extensions/integrations.py b/src/common/extensions/integrations.py new file mode 100644 index 00000000..049b53ad --- /dev/null +++ b/src/common/extensions/integrations.py @@ -0,0 +1,192 @@ +""" +common > extensions > integrations + +Code responsible for registering and managing integrations + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" +from ctypes import Union +from typing import TYPE_CHECKING, Optional +from common.types.decorator import Decorator +from common.plug_indexes import WindowIndex + +if TYPE_CHECKING: + from integrations import Integration + + +_plugin_integrations: dict[str, 'Integration'] = {} +"""Integrations with plugins""" + +_window_integrations: dict[WindowIndex, 'Integration'] = {} +"""Integrations with FL Studio windows""" + +_core_pre_integrations: list['Integration'] = [] +"""Core integrations (preprocessed)""" + +_core_post_integrations: list['Integration'] = [] +"""Core integrations (postprocessed)""" + + +def register_plugin( + plugin_name: str, +) -> Decorator[type['Integration'], type['Integration']]: + """ + Register an integration with a plugin (FL or VST), given the plugin name. + + ### Usage + + ```py + @integrations.register_plugin("Flex") + class MyIntegration(Integration): + ... + ``` + + ### Args + * `plugin_name` (`str`): name of the plugin for which this integration + should be used + + ### Returns + * `Decorator[type[Integration]]`: a decorator function that registers the + integration definition + """ + def inner(integration: type['Integration']) -> type['Integration']: + if (found_int := _plugin_integrations.get(plugin_name)) is not None: + raise ValueError( + f"An integration matching plugin name {plugin_name} has " + f"already been registered.\n\n" + f"Attempted to register: {integration}\n" + f"Previously registered integration: {found_int}" + ) + _plugin_integrations[plugin_name] = integration + return integration + + return inner + + +def register_window( + idx: WindowIndex, +) -> Decorator[type['Integration'], type['Integration']]: + """ + Register an integration with an FL Studio window, given its window index. + + ### Usage + + ```py + @integrations.register_window(WindowIndex.CHANNEL_RACK) + class MyIntegration(Integration): + ... + ``` + + ### Args + * `idx` (`WindowIndex`): index of the window for which this integration + should be used + + ### Returns + * `Decorator[type[Integration]]`: a decorator function that registers the + integration definition + """ + def inner(integration: type['Integration']) -> type['Integration']: + if (found_int := _window_integrations.get(idx)) is not None: + raise ValueError( + f"An integration matching window index {idx} has " + f"already been registered.\n\n" + f"Attempted to register: {integration}\n" + f"Previously registered integration: {found_int}" + ) + _window_integrations[idx] = integration + return integration + + return inner + + +def register_core(*, preprocess: bool) -> Union[ + type['Integration'], + Decorator[type['Integration'], type['Integration']] +]: + """ + Register a core integration with the script. Core integrations are always + active, and can be used to make a feature set be always in use. + + ### Usage + + ```py + @integrations.register_core(preprocess=True) + class MyIntegration(Integration): + ... + ``` + + ### Args + * `preprocess`(`bool`): whether the integration should process events + before other integrations (`True`) or after other integrations (`False`) + + ### Returns + * `Decorator[type[Integration]]`: a decorator function that registers the + integration definition + """ + if preprocess: + integration_list = _core_pre_integrations + else: + integration_list = _core_post_integrations + + def inner(integration: type['Integration']) -> type['Integration']: + integration_list.append(integration) + return integration + + return inner + + +def get_integration_for_plugin( + plugin_name: str, +) -> Optional[type['Integration']]: + """ + Returns an integration definition that matches the given plugin name, if + such an integration is registered. + + ### Args + * `plugin_name` (`str`): name of plugin to find an integration for + + ### Returns + * `type[Integration]`: integration definition, if found + * `None`: if no matches + """ + return _plugin_integrations.get(plugin_name) + + +def get_integration_for_window( + window_index: WindowIndex, +) -> Optional[type['Integration']]: + """ + Returns an integration definition that matches the given FL Studio window + index, if such an integration is registered. + + ### Args + * `window_index` (`WindowIndex`): FL Studio window index + + ### Returns + * `type[Integration]`: integration definition, if found + * `None`: if no matches + """ + + +def get_core_preprocess_integrations() -> list[type['Integration']]: + """ + Returns a list of all registered preprocessing core integrations. + + ### Returns + * `list[type[Integration]]`: list of core integration definitions + """ + return _core_pre_integrations + + +def get_core_postprocess_integrations() -> list[type['Integration']]: + """ + Returns a list of all registered postprocessing core integrations. + + ### Returns + * `list[type[Integration]]`: list of core integration definitions + """ + return _core_post_integrations From c3c7bfa5afe6e8fce494fb0445d494275cc553ae Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 01:21:40 +1100 Subject: [PATCH 06/15] Remove old extension manager code --- src/common/extension_manager/__init__.py | 18 -- src/common/extension_manager/devices.py | 138 -------------- .../extension_manager/extension_manager.py | 176 ------------------ src/common/extension_manager/special_plugs.py | 86 --------- .../extension_manager/standard_plugs.py | 155 --------------- src/common/extension_manager/window_plugs.py | 132 ------------- 6 files changed, 705 deletions(-) delete mode 100644 src/common/extension_manager/__init__.py delete mode 100644 src/common/extension_manager/devices.py delete mode 100644 src/common/extension_manager/extension_manager.py delete mode 100644 src/common/extension_manager/special_plugs.py delete mode 100644 src/common/extension_manager/standard_plugs.py delete mode 100644 src/common/extension_manager/window_plugs.py diff --git a/src/common/extension_manager/__init__.py b/src/common/extension_manager/__init__.py deleted file mode 100644 index 70c0134f..00000000 --- a/src/common/extension_manager/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -common > extension_manager - -Contains the system for managing script extensions, including devices and -plugins. - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -__all__ = [ - 'ExtensionManager', -] - -from .extension_manager import ExtensionManager diff --git a/src/common/extension_manager/devices.py b/src/common/extension_manager/devices.py deleted file mode 100644 index 79ac283f..00000000 --- a/src/common/extension_manager/devices.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -common > extension_manager > devices - -Contains the definition for the DeviceCollection class - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" -from typing import TYPE_CHECKING -from common.exceptions import DeviceRecognizeError, DeviceInitializeError -from common.util.events import eventToString -from fl_classes import FlMidiMsg - - -if TYPE_CHECKING: - from devices import Device - - -class DeviceCollection: - """Collection of devices registered to the script - """ - def __init__(self) -> None: - self.__devices: list[type['Device']] = [] - - def register(self, device: type['Device']) -> None: - """ - Register a standard plugin - - This should be called after defining the class object for a plugin, so - that the class can be instantiated if the plugin is in use. - - ### Args: - * `device` (`type[Device]`): device to register - - ### Example Usage - ```py - # Create a device - class MyDevice(Device): - ... - # Register it - ExtensionManager.devices.register(MyDevice) - ``` - """ - self.__devices.append(device) - - def get(self, arg: 'FlMidiMsg | str') -> 'Device': - """ - Returns a new instance of a device, given a universal device enquiry - response or a device identifier (as a fallback) - - ### Args: - * `arg` (``FlMidiMsg | str`): event to match with devices - - ### Raises: - * `ValueError`: Device not recognized - - ### Returns: - * `Device`: device object instance - """ - # Device name - if isinstance(arg, str): - for device in self.__devices: - if device.matchDeviceName(arg): - # If it matches the pattern, then we found the right device - # create an instance and return it - try: - return device.create(None) - except Exception as e: - raise DeviceInitializeError( - "Failed to initialise device") from e - raise DeviceRecognizeError( - f"Device not recognized, using device name {arg}") - # Sysex event - # elif isinstance(arg, FlMidiMsg): - # Can't runtime type check for MIDI events - else: - for device in self.__devices: - pattern = device.getUniversalEnquiryResponsePattern() - if pattern is None: - pass - elif pattern.matchEvent(arg): - # If it matches the pattern, then we found the right device - # create an instance and return it - try: - return device.create(arg) - except Exception as e: - raise DeviceInitializeError( - "Failed to initialise device") from e - raise DeviceRecognizeError( - f"Device not recognized, using response " - f"pattern {eventToString(arg)}" - ) - - def getById(self, id: str) -> 'Device': - """ - Returns a new instance of a device, given a device ID, which should - match a return value of Device.getId() - - FIXME: This doesn't work since device IDs aren't statically determined - - ### Raises: - * `ValueError`: Device not found - - ### Returns: - * `Device`: matching device - """ - for device in self.__devices: - if id in device.getSupportedIds(): - try: - return device.create(id=id) - except Exception as e: - raise DeviceInitializeError( - "Failed to initialise device") from e - raise DeviceRecognizeError(f"Device with ID {id} not found") - - def all(self) -> list[type['Device']]: - return list(self.__devices) - - def __len__(self) -> int: - return len(self.__devices) - - def inspect(self, dev: type['Device']) -> str: - """ - Returns info about a device - - ### Args: - * `dev` (`type[Device]`): device to inspect - - ### Returns: - * `str`: device info - """ - if dev in self.__devices: - return f"{dev} (registered)" - else: - return f"{dev} (not registered)" diff --git a/src/common/extension_manager/extension_manager.py b/src/common/extension_manager/extension_manager.py deleted file mode 100644 index 41d2f961..00000000 --- a/src/common/extension_manager/extension_manager.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -common > extension_manager > extension_manager - -Contains the static class for registering extensions to the script, including -device and plugin definitions. - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -from typing import TYPE_CHECKING -from common.util.console_helpers import printReturn - -if TYPE_CHECKING: - from devices import Device - from integrations import Integration - -from .standard_plugs import StandardPluginCollection -from .special_plugs import SpecialPluginCollection -from .window_plugs import WindowPluginCollection -from .devices import DeviceCollection - - -class ExtensionManager: - """ - Manages all extensions registered with the script, allowing for extensions - to be used for plugins or devices as required. - - WARNING: Plugins assume that device definitions don't change over time. - If the active device changes, or the available controls change, the - function `resetPlugins()` should be called so that plugins are - reset to their default state and control bindings are removed. - """ - - # Standard plugins - plugins = StandardPluginCollection() - """Contains standard plugins - """ - - # Window plugins - windows = WindowPluginCollection() - """Contains plugins that handle FL Studio windows - """ - - # Special plugins - special = SpecialPluginCollection() - """Special plugins are plugins that can be active at any time they specify - """ - - # Final special plugins - super_special = SpecialPluginCollection() - """Super special plugins process events first and draw lights last, meaning - that they get the first opportunity to process events, and the final say on - lighting behavior. - """ - - # Devices - devices = DeviceCollection() - """Hardware device definitions - """ - - def __init__(self) -> None: - raise TypeError( - "ExtensionManager is a static class and cannot be instantiated." - ) - - @classmethod - def resetPlugins(cls) -> None: - """ - Resets all active plugins (standard and special) which can account for - a device change. - """ - cls.plugins.reset() - cls.windows.reset() - cls.special.reset() - cls.super_special.reset() - - @classmethod - def getInfo(cls) -> str: - """ - Returns more detailed info about the devices and plugins registered - with the extension manager - - ### Returns: - * `str`: info - """ - def plural(obj) -> str: - return 's' if len(obj) != 1 else '' - - def instantiated(obj) -> str: - return f" ({len(obj)} instantiated)" if len(obj) else "" - - # Number of devices - n_dev = f"{len(cls.devices)} device{plural(cls.devices)}" - # Number of plugins - n_plug = f"{len(cls.plugins)} plugin{plural(cls.plugins)}" - # Number of instantiated plugins - ni_plug = instantiated(cls.plugins.instantiated()) - # Number of windows - n_wind = f"{len(cls.windows)} window plugin{plural(cls.windows)}" - # Number of instantiated windows - ni_wind = instantiated(cls.windows.instantiated()) - # Number of special plugins - ns_plug = f"{len(cls.special)} "\ - f"special plugin{plural(cls.special)}" - # Number of instantiated special plugins - nis_plug = instantiated(cls.special.instantiated()) - # Number of final special plugins - nfs_plug = f"{len(cls.super_special)} "\ - f"final special plugin{plural(cls.super_special)}" - # Number of instantiated final special plugins - nifs_plug = instantiated(cls.super_special) - # Compile all that info into one string - return ( - f"{n_dev}, " - f"{n_plug}{ni_plug}, " - f"{n_wind}{ni_wind}, " - f"{ns_plug}{nis_plug}, " - f"{nfs_plug}{nifs_plug}" - ) - - @classmethod - def getBasicInfo(cls) -> str: - """ - Returns basic info about the devices and plugins registered with the - extension manager - - ### Returns: - * `str`: info - """ - def plural(obj) -> str: - return 's' if len(obj) != 1 else '' - - # Number of devices - n_dev = len(cls.devices) - # Number of plugins - n_plug = ( - len(cls.plugins) + len(cls.windows) - + len(cls.special) + len(cls.super_special) - ) - return ( - f"{n_dev} devices, {n_plug} plugins" - ) - - @classmethod - @printReturn - def inspect(cls, ext: 'type[Device] | type[Integration] | str') -> str: - """ - Returns info about an extension, which can be a device or a plugin of - any kind - - ### Args: - * `ext` (`type[Plugin] | type[Plugin] | str`): device, plugin or - plugin ID to inspect - - ### Returns: - * `str`: extension info - """ - import devices - import integrations - if isinstance(ext, str): - return cls.plugins.inspect(ext) - elif issubclass(ext, devices.Device): - return cls.devices.inspect(ext) - elif issubclass(ext, integrations.PluginIntegration): - return cls.plugins.inspect(ext) - elif issubclass(ext, integrations.WindowIntegration): - return cls.windows.inspect(ext) - elif issubclass(ext, integrations.CoreIntegration): - # FIXME: Final plugins can't be inspected - return cls.special.inspect(ext) - else: - return f"{ext} isn't a Plugin class or plugin ID" diff --git a/src/common/extension_manager/special_plugs.py b/src/common/extension_manager/special_plugs.py deleted file mode 100644 index 731358a2..00000000 --- a/src/common/extension_manager/special_plugs.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -common > extension_manager > special_plugs - -Contains the definition for the SpecialPluginCollection class - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from integrations import CoreIntegration - from devices import Device - - -class SpecialPluginCollection: - """Collection of special plugins registered to the script. - """ - def __init__(self) -> None: - self.__types: list[type['CoreIntegration']] = [] - self.__instantiated: dict[type['CoreIntegration'], 'CoreIntegration']\ - = {} - - def register(self, plug: type['CoreIntegration']) -> None: - """ - Register a special plugin - - This should be called after defining the class object for a plugin, so - that the class can be instantiated if the plugin is in use. - - ### Args: - * `plug` (`type[SpecialPlugin]`): plugin to register - - ### Example Usage - ```py - # Create a plugin - class MyPlugin(SpecialPlugin): - ... - # Register it - ExtensionManager.special.register(MyPlugin) - # Or register it as a final processor - ExtensionManager.final.register(MyPlugin) - ``` - """ - self.__types.append(plug) - - def get(self, device: 'Device') -> list['CoreIntegration']: - """Get a list of all the active special plugins - """ - from devices.device_shadow import DeviceShadow - ret: list[CoreIntegration] = [] - for p in self.__types: - # If plugin should be active - if p.shouldBeActive(): - # If it hasn't been instantiated yet, instantiate it - if p not in self.__instantiated.keys(): - self.__instantiated[p] = p.create(DeviceShadow(device)) - ret.append(self.__instantiated[p]) - return ret - - def reset(self) -> None: - self.__instantiated = {} - - def all(self) -> list[type['CoreIntegration']]: - return list(self.__types) - - def instantiated(self) -> list['CoreIntegration']: - return list(self.__instantiated.values()) - - def __len__(self) -> int: - return len(self.__types) - - def __contains__(self, other: type['CoreIntegration']) -> bool: - return other in self.__types - - def inspect(self, plug: type['CoreIntegration']) -> str: - if plug in self.__instantiated.keys(): - return str(self.__instantiated[plug]) - elif plug in self.__types: - return f"{plug} (Not instantiated)" - else: - return f"{plug} isn't registered" diff --git a/src/common/extension_manager/standard_plugs.py b/src/common/extension_manager/standard_plugs.py deleted file mode 100644 index 5fa52208..00000000 --- a/src/common/extension_manager/standard_plugs.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -common > extension_manager > standard_plugs - -Contains the definition for the StandardPluginCollection class - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from integrations import PluginIntegration - from devices import Device - - -class StandardPluginCollection: - """Collection of standard plugins registered to the script - """ - def __init__(self) -> None: - self.__mappings: dict[str, type['PluginIntegration']] = {} - self.__instantiated: dict[str, 'PluginIntegration'] = {} - self.__fallback: Optional[type['PluginIntegration']] = None - self.__fallback_inst: Optional['PluginIntegration'] = None - - def register(self, plug: type['PluginIntegration']) -> None: - """ - Register a standard plugin - - This should be called after defining the class object for a plugin, so - that the class can be instantiated if the plugin is in use. - - ### Args: - * `plug` (`type[StandardPlugin]`): plugin to register - - ### Example Usage - ```py - # Create a plugin - class MyPlugin(StandardPlugin): - ... - # Register it - ExtensionManager.plugins.register(MyPlugin) - ``` - """ - for plug_id in plug.getPlugIds(): - self.__mappings[plug_id] = plug - - def registerFallback(self, plug: type['PluginIntegration']) -> None: - """ - Register a plugin to be used as a fallback when the default bindings - fail - - If a matching plugin isn't found, the fallback plugin is returned. - - ### Args: - * `plug` (`type[StandardPlugin]`): plugin to register - """ - self.__fallback = plug - - def get(self, id: str, device: 'Device') -> Optional['PluginIntegration']: - """Get an instance of the plugin matching this plugin id - """ - from devices.device_shadow import DeviceShadow - # Plugin already instantiated - if id in self.__instantiated.keys(): - return self.__instantiated[id] - # Plugin exists but isn't instantiated - elif id in self.__mappings.keys(): - self.__instantiated[id] \ - = self.__mappings[id].create(DeviceShadow(device)) - return self.__instantiated[id] - # Plugin doesn't exist - else: - if self.__fallback_inst is None: - if self.__fallback is None: - return None - else: - self.__fallback_inst \ - = self.__fallback.create(DeviceShadow(device)) - return self.__fallback_inst - - def getFallback(self) -> Optional['PluginIntegration']: - """Return the fallback plugin if registered - """ - return self.__fallback_inst - - def reset(self) -> None: - self.__instantiated = {} - self.__fallback_inst = None - - def all(self) -> list[type['PluginIntegration']]: - return list(self.__mappings.values()) - - def instantiated(self) -> list['PluginIntegration']: - return list(self.__instantiated.values()) - - def __len__(self) -> int: - return len(self.__mappings) - - def _formatPlugin(cls, plug: Optional['PluginIntegration']) -> str: - """ - Format info about a plugin instance - - ### Args: - * `plug` (`Optional[Plugin]`): plugin instance or None - - ### Returns: - * `str`: formatted info - """ - if plug is None: - return "(Not instantiated)" - else: - return repr(plug) - - def inspect(self, plug: 'type[PluginIntegration] | str') -> str: - if isinstance(plug, str): - return self._inspect_id(plug) - else: - return self._inspect_plug(plug) - - def _inspect_plug(self, plug: 'type[PluginIntegration]') -> str: - matches: list[tuple[str, Optional['PluginIntegration']]] = [] - - for id, p in self.__mappings.items(): - if p == plug: - if id in self.__instantiated.keys(): - matches.append((id, self.__instantiated[id])) - else: - matches.append((id, None)) - - if len(matches) == 0: - return f"Plugin {plug} isn't associated with any plugins" - elif len(matches) == 1: - id, inst_p = matches[0] - return ( - f"{plug} associated with:\n{id}\n" - f"{self._formatPlugin(inst_p)}" - ) - else: - return f"{plug}:" + "\n\n".join([ - f"> {id}:\n{self._formatPlugin(inst_p)}" - for id, inst_p in matches - ]) - - def _inspect_id(self, id: str) -> str: - if id in self.__instantiated.keys(): - return f"{id} associated with:\n\n{self.__instantiated[id]}" - elif id in self.__mappings.keys(): - return f"{id} associated with: {self.__mappings[id]} "\ - "(not instantiated)" - else: - return f"ID {id} not associated with any plugins" diff --git a/src/common/extension_manager/window_plugs.py b/src/common/extension_manager/window_plugs.py deleted file mode 100644 index 8c383010..00000000 --- a/src/common/extension_manager/window_plugs.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -common > extension_manager > window_plugs - -Contains the definition for the WindowPluginCollection class - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from integrations import WindowIntegration - from devices import Device - from common.plug_indexes.fl_index import WindowIndex - - -class WindowPluginCollection: - """Collection of window plugins registered to the script - """ - def __init__(self) -> None: - self.__mappings: dict[WindowIndex, type['WindowIntegration']] = {} - self.__instantiated: dict[WindowIndex, 'WindowIntegration'] = {} - - def register(self, plug: type['WindowIntegration']) -> None: - """ - Register a window plugin - - This should be called after defining the class object for a plugin, so - that the class can be instantiated if the plugin is in use. - - ### Args: - * `plug` (`type[WindowPlugin]`): plugin to register - - ### Example Usage - ```py - # Create a plugin - class MyPlugin(WindowPlugin): - ... - # Register it - ExtensionManager.windows.register(MyPlugin) - ``` - """ - self.__mappings[plug.getWindowId()] = plug - - def get( - self, - id: 'WindowIndex', - device: 'Device' - ) -> Optional['WindowIntegration']: - """Get an instance of the plugin matching this window index - """ - from devices.device_shadow import DeviceShadow - # Plugin already instantiated - if id in self.__instantiated.keys(): - return self.__instantiated[id] - # Plugin exists but isn't instantiated - elif id in self.__mappings.keys(): - self.__instantiated[id] \ - = self.__mappings[id].create(DeviceShadow(device)) - return self.__instantiated[id] - # Plugin doesn't exist - else: - # log( - # "extensions.manager", - # f"No plugins associated with plugin ID '{id}'", - # verbosity=verbosity.NOTE - # ) - return None - - def reset(self) -> None: - self.__instantiated = {} - - def all(self) -> list[type['WindowIntegration']]: - return list(self.__mappings.values()) - - def instantiated(self) -> list['WindowIntegration']: - return list(self.__instantiated.values()) - - def __len__(self) -> int: - return len(self.__mappings) - - def _formatPlugin(cls, plug: Optional['WindowIntegration']) -> str: - """ - Format info about a plugin instance - - ### Args: - * `plug` (`Optional[Plugin]`): plugin instance or None - - ### Returns: - * `str`: formatted info - """ - if plug is None: - return "(Not instantiated)" - else: - return repr(plug) - - def inspect(self, plug: type['WindowIntegration']) -> str: - """ - Returns info about a window plugin - - ### Args: - * `plug` (`type[WindowPlugin]`): plugin to inspect - - ### Returns: - * `str`: plugin info - """ - matches: list[tuple['WindowIndex', Optional['WindowIntegration']]] = [] - - for id, p in self.__mappings.items(): - if p == plug: - if id in self.__instantiated.keys(): - matches.append((id, self.__instantiated[id])) - else: - matches.append((id, None)) - - if len(matches) == 0: - return f"Plugin {plug} isn't associated with any plugins" - elif len(matches) == 1: - id, inst_p = matches[0] - return ( - f"{plug} associated with:\n{id}\n" - f"{self._formatPlugin(inst_p)}" - ) - else: - return f"{plug}:" + "\n\n".join([ - f"> {id}:\n{self._formatPlugin(inst_p)}" - for id, inst_p in matches - ]) From a2daaef9cfa59c497ded741c575b9f90d11cad9e Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 01:22:02 +1100 Subject: [PATCH 07/15] Add some words to spelling dictionary --- .vscode/settings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index a207dd7c..3cf60d60 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -53,6 +53,8 @@ "playhead", "plugins", "pmeflags", + "postprocess", + "postprocessed", "rect", "Roadmap", "RRGGBB", From fdcba6d6b18459926a268a32e5c58f141c422cae Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 19:58:35 +1100 Subject: [PATCH 08/15] Fix type errors for extension manager --- src/common/extensions/devices.py | 12 +++--- src/common/extensions/integrations.py | 23 ++++++----- src/common/types/decorator.py | 57 +++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/common/extensions/devices.py b/src/common/extensions/devices.py index 799af658..25b74f40 100644 --- a/src/common/extensions/devices.py +++ b/src/common/extensions/devices.py @@ -10,13 +10,14 @@ more details. """ from typing import TYPE_CHECKING, Optional -from common.types.decorator import Decorator +from common.types.decorator import SymmetricDecorator +from common.util.events import bytesToString if TYPE_CHECKING: from devices import Device -_devices: dict[bytes, 'Device'] = {} +_devices: dict[bytes, type['Device']] = {} def get_device_matching_id(device_id: bytes) -> Optional[type['Device']]: @@ -45,7 +46,7 @@ def get_device_matching_id(device_id: bytes) -> Optional[type['Device']]: return None -def register(device_id: bytes) -> Decorator[type['Device'], type['Device']]: +def register(device_id: bytes) -> SymmetricDecorator[type['Device']]: """ Register a device definition to be associated with the given `device_id` @@ -74,9 +75,10 @@ def inner(device_definition: type['Device']) -> type['Device']: # message, and to make sure people don't abuse decorators to register # multiple devices with one outer `register` call if (found_dev := get_device_matching_id(device_id)) is not None: + device_id_str = bytesToString(device_id) raise ValueError( - f"A device matching device_id {device_id} has already been " - f"registered.\n\n" + f"A device matching device_id {device_id_str} has already " + f"been registered.\n\n" f"Attempted to register: {device_definition}\n" f"Previously registered device: {found_dev}" ) diff --git a/src/common/extensions/integrations.py b/src/common/extensions/integrations.py index 049b53ad..e3f58d26 100644 --- a/src/common/extensions/integrations.py +++ b/src/common/extensions/integrations.py @@ -9,31 +9,30 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ -from ctypes import Union from typing import TYPE_CHECKING, Optional -from common.types.decorator import Decorator +from common.types.decorator import SymmetricDecorator from common.plug_indexes import WindowIndex if TYPE_CHECKING: from integrations import Integration -_plugin_integrations: dict[str, 'Integration'] = {} +_plugin_integrations: dict[str, type['Integration']] = {} """Integrations with plugins""" -_window_integrations: dict[WindowIndex, 'Integration'] = {} +_window_integrations: dict[WindowIndex, type['Integration']] = {} """Integrations with FL Studio windows""" -_core_pre_integrations: list['Integration'] = [] +_core_pre_integrations: list[type['Integration']] = [] """Core integrations (preprocessed)""" -_core_post_integrations: list['Integration'] = [] +_core_post_integrations: list[type['Integration']] = [] """Core integrations (postprocessed)""" def register_plugin( plugin_name: str, -) -> Decorator[type['Integration'], type['Integration']]: +) -> SymmetricDecorator[type['Integration']]: """ Register an integration with a plugin (FL or VST), given the plugin name. @@ -69,7 +68,7 @@ def inner(integration: type['Integration']) -> type['Integration']: def register_window( idx: WindowIndex, -) -> Decorator[type['Integration'], type['Integration']]: +) -> SymmetricDecorator[type['Integration']]: """ Register an integration with an FL Studio window, given its window index. @@ -103,10 +102,10 @@ def inner(integration: type['Integration']) -> type['Integration']: return inner -def register_core(*, preprocess: bool) -> Union[ - type['Integration'], - Decorator[type['Integration'], type['Integration']] -]: +def register_core( + *, + preprocess: bool, +) -> SymmetricDecorator[type['Integration']]: """ Register a core integration with the script. Core integrations are always active, and can be used to make a feature set be always in use. diff --git a/src/common/types/decorator.py b/src/common/types/decorator.py index 14ccc7bf..39684457 100644 --- a/src/common/types/decorator.py +++ b/src/common/types/decorator.py @@ -1,14 +1,22 @@ from typing import Protocol, TypeVar, Generic +# Hopefully in the future, we can specify that TOut defaults to TIn, but this +# requires the acceptance of PEP 696: https://peps.python.org/pep-0696/ +# Instead, we'll just have to accept a more yucky subclassing system as per +# https://stackoverflow.com/a/77586364/6335363 TIn = TypeVar('TIn', contravariant=True) TOut = TypeVar('TOut', covariant=True) +TSym = TypeVar('TSym') class Decorator(Protocol, Generic[TIn, TOut]): """ Represents a decorated value, used to simplify type definitions. + If the input type matches the output type, consider using + `SymmetricDecorator` for a simpler type defintion. + ### Usage For functions that return a decorator function, annotate the return type as @@ -16,6 +24,51 @@ class Decorator(Protocol, Generic[TIn, TOut]): ### Example + If we have the following type aliases: + + ```py + IntFunction = Callable[[int, int], int] + ``` + + And the following function that matches its definition: + + ```py + def add(a: int, b: int) -> int: + return a + b + ``` + + We can type a decorator function as follows: + + ```py + TFunction = Callable[[T, T], T] + + def int_fn_to_t_fn(t: type[T]) -> Decorator[IntFunction, TFunction]: + def inner(fn: IntFunction) -> TFunction: + def wrapper(a: T, b: T) -> T: + a_int = int(a) + b_int = int(b) + return t(fn(a, b)) + return wrapper + return inner + ``` + """ + def __call__(self, value: TIn, /) -> TOut: + ... + + +class SymmetricDecorator(Decorator[TSym, TSym], Generic[TSym], Protocol): + """ + Represents a decorated value, used to simplify type definitions. + + `SymmetricDecorator` is used if the input type matches the output type. + + ### Usage + + For functions that return a decorator function, annotate the return type as + `Decorator[ArgumentType]` + + ### Example + If we have the following type alias: ```py @@ -32,12 +85,10 @@ def add(a: int, b: int) -> int: We can type a decorator function as follows: ```py - def register_operator(op: str) -> Decorator[IntFunction, IntFunction]: + def register_operator(op: str) -> SymmetricDecorator[IntFunction]: def inner(value: IntFunction) -> IntFunction: # register the function or whatever return value return inner ``` """ - def __call__(self, value: TIn) -> TOut: - ... From 7481a779a5e9648939a5cf05ee0140af878a1cb4 Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sat, 2 Dec 2023 22:37:00 +1100 Subject: [PATCH 09/15] Commit changes that break everything even more Just want my git status to be clean --- src/common/__init__.py | 2 -- src/common/extensions/devices.py | 2 +- src/device_event_forward.py | 2 +- src/devices/akai/mpk_mini_mk3.py | 2 +- src/devices/korg/nano_kontrol/mk1/nano_kontrol.py | 2 +- src/devices/maudio/hammer88pro/hammer88pro.py | 2 +- src/devices/novation/launchkey/mk2/launchkey.py | 2 +- src/devices/novation/launchkey/mk3/lk_25_37.py | 2 +- src/devices/novation/launchkey/mk3/lk_49_61.py | 2 +- src/devices/novation/launchkey/mk3_mini/mini.py | 2 +- src/devices/novation/sl/mk3/device.py | 2 +- src/integrations/core/activity_switcher.py | 2 +- src/integrations/core/fallback_transport.py | 2 +- src/integrations/core/macro.py | 2 +- src/integrations/core/manual_mapper.py | 2 +- src/integrations/core/pressed.py | 2 +- src/integrations/core/transport.py | 2 +- src/integrations/plugin/basic_faders.py | 2 +- src/integrations/plugin/default_integration.py | 2 +- src/integrations/plugin/fl/flex.py | 2 +- src/integrations/plugin/fl/fpc.py | 2 +- src/integrations/plugin/fl/parametric_eq.py | 2 +- src/integrations/plugin/fl/slicers.py | 2 +- src/integrations/plugin/klevgrand/daw_cassette.py | 2 +- src/integrations/plugin/spitfire/spitfire_generic.py | 2 +- src/integrations/window/browser.py | 2 +- src/integrations/window/channel_rack/plug.py | 2 +- src/integrations/window/mixer.py | 2 +- src/integrations/window/piano_roll.py | 2 +- src/integrations/window/playlist.py | 2 +- 30 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/common/__init__.py b/src/common/__init__.py index 071c9b79..6084ea76 100644 --- a/src/common/__init__.py +++ b/src/common/__init__.py @@ -37,8 +37,6 @@ catchContextResetException ) -from .extension_manager import ExtensionManager - # Import devices and plugins import devices import integrations diff --git a/src/common/extensions/devices.py b/src/common/extensions/devices.py index 25b74f40..3f7c2456 100644 --- a/src/common/extensions/devices.py +++ b/src/common/extensions/devices.py @@ -89,7 +89,7 @@ def inner(device_definition: type['Device']) -> type['Device']: return inner -def get_registered_devices() -> dict[bytes, 'Device']: +def get_registered_devices() -> dict[bytes, type['Device']]: """ Returns a dictionary mapping registered device IDs to the corresponding device definitions diff --git a/src/device_event_forward.py b/src/device_event_forward.py index c63732da..0a1f3d11 100644 --- a/src/device_event_forward.py +++ b/src/device_event_forward.py @@ -29,7 +29,7 @@ from fl_param_checker import idleCallback, pluginParamCheck from common.context_manager import catchContextResetException -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.states import WaitingForDevice, ForwardState from common import log, verbosity diff --git a/src/devices/akai/mpk_mini_mk3.py b/src/devices/akai/mpk_mini_mk3.py index 7199bc92..18cc7df6 100644 --- a/src/devices/akai/mpk_mini_mk3.py +++ b/src/devices/akai/mpk_mini_mk3.py @@ -17,7 +17,7 @@ UnionPattern, ) from control_surfaces.value_strategies import Data2Strategy -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from control_surfaces import ( Knob, diff --git a/src/devices/korg/nano_kontrol/mk1/nano_kontrol.py b/src/devices/korg/nano_kontrol/mk1/nano_kontrol.py index 23c87d75..9c15fa46 100644 --- a/src/devices/korg/nano_kontrol/mk1/nano_kontrol.py +++ b/src/devices/korg/nano_kontrol/mk1/nano_kontrol.py @@ -15,7 +15,7 @@ """ from typing import Optional -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from control_surfaces.event_patterns import IEventPattern, BasicPattern from fl_classes import FlMidiMsg from control_surfaces.value_strategies import ( diff --git a/src/devices/maudio/hammer88pro/hammer88pro.py b/src/devices/maudio/hammer88pro/hammer88pro.py index 85e482a9..6fab352e 100644 --- a/src/devices/maudio/hammer88pro/hammer88pro.py +++ b/src/devices/maudio/hammer88pro/hammer88pro.py @@ -20,7 +20,7 @@ ForwardedUnionPattern, NotePattern, ) -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from devices import Device from control_surfaces.matchers import ( diff --git a/src/devices/novation/launchkey/mk2/launchkey.py b/src/devices/novation/launchkey/mk2/launchkey.py index 569b38f5..ad005951 100644 --- a/src/devices/novation/launchkey/mk2/launchkey.py +++ b/src/devices/novation/launchkey/mk2/launchkey.py @@ -14,7 +14,7 @@ import device from control_surfaces.event_patterns import BasicPattern -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from control_surfaces import ( StandardModWheel, diff --git a/src/devices/novation/launchkey/mk3/lk_25_37.py b/src/devices/novation/launchkey/mk3/lk_25_37.py index d5dd628b..cdff5d7b 100644 --- a/src/devices/novation/launchkey/mk3/lk_25_37.py +++ b/src/devices/novation/launchkey/mk3/lk_25_37.py @@ -14,7 +14,7 @@ import device from control_surfaces.event_patterns import BasicPattern -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from control_surfaces import ( StandardModWheel, diff --git a/src/devices/novation/launchkey/mk3/lk_49_61.py b/src/devices/novation/launchkey/mk3/lk_49_61.py index 3368887b..176b14a2 100644 --- a/src/devices/novation/launchkey/mk3/lk_49_61.py +++ b/src/devices/novation/launchkey/mk3/lk_49_61.py @@ -14,7 +14,7 @@ import device from control_surfaces.event_patterns import BasicPattern -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from control_surfaces import ( StandardModWheel, diff --git a/src/devices/novation/launchkey/mk3_mini/mini.py b/src/devices/novation/launchkey/mk3_mini/mini.py index 86a4ddec..a11e4522 100644 --- a/src/devices/novation/launchkey/mk3_mini/mini.py +++ b/src/devices/novation/launchkey/mk3_mini/mini.py @@ -14,7 +14,7 @@ import device from control_surfaces.event_patterns import BasicPattern -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from control_surfaces import ( StandardModWheel, diff --git a/src/devices/novation/sl/mk3/device.py b/src/devices/novation/sl/mk3/device.py index 33931677..35865a53 100644 --- a/src/devices/novation/sl/mk3/device.py +++ b/src/devices/novation/sl/mk3/device.py @@ -14,7 +14,7 @@ import device from control_surfaces.event_patterns import BasicPattern -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from fl_classes import FlMidiMsg from control_surfaces import ( StandardModWheel, diff --git a/src/integrations/core/activity_switcher.py b/src/integrations/core/activity_switcher.py index bce274ac..41084b38 100644 --- a/src/integrations/core/activity_switcher.py +++ b/src/integrations/core/activity_switcher.py @@ -14,7 +14,7 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common import getContext from common.types import Color from common.plug_indexes import ( diff --git a/src/integrations/core/fallback_transport.py b/src/integrations/core/fallback_transport.py index 506d43db..3dd541fc 100644 --- a/src/integrations/core/fallback_transport.py +++ b/src/integrations/core/fallback_transport.py @@ -11,7 +11,7 @@ more details. """ -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from devices import DeviceShadow from integrations import CoreIntegration from integrations.mapping_strategies import ( diff --git a/src/integrations/core/macro.py b/src/integrations/core/macro.py index b6875f9a..6aac4ca5 100644 --- a/src/integrations/core/macro.py +++ b/src/integrations/core/macro.py @@ -15,7 +15,7 @@ import transport from typing import Any -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common import getContext from common.types import Color from common.util.api_fixes import getUndoPosition diff --git a/src/integrations/core/manual_mapper.py b/src/integrations/core/manual_mapper.py index 6e9825f9..533b953c 100644 --- a/src/integrations/core/manual_mapper.py +++ b/src/integrations/core/manual_mapper.py @@ -18,7 +18,7 @@ import general import midi from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from control_surfaces import ( GenericFader, GenericKnob, diff --git a/src/integrations/core/pressed.py b/src/integrations/core/pressed.py index 1c407457..8b1290ff 100644 --- a/src/integrations/core/pressed.py +++ b/src/integrations/core/pressed.py @@ -14,7 +14,7 @@ from typing import Any from time import time from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from control_surfaces import ( DrumPad, Button, diff --git a/src/integrations/core/transport.py b/src/integrations/core/transport.py index db2f05f5..5be70a46 100644 --- a/src/integrations/core/transport.py +++ b/src/integrations/core/transport.py @@ -18,7 +18,7 @@ from typing import Any from common.context_manager import getContext -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.types import Color from control_surfaces import ( ControlShadowEvent, diff --git a/src/integrations/plugin/basic_faders.py b/src/integrations/plugin/basic_faders.py index 945443f2..70044b31 100644 --- a/src/integrations/plugin/basic_faders.py +++ b/src/integrations/plugin/basic_faders.py @@ -13,7 +13,7 @@ """ from typing import Union from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from devices import DeviceShadow from integrations import PluginIntegration from integrations.mapping_strategies import ( diff --git a/src/integrations/plugin/default_integration.py b/src/integrations/plugin/default_integration.py index cb62fad8..fcf57f78 100644 --- a/src/integrations/plugin/default_integration.py +++ b/src/integrations/plugin/default_integration.py @@ -10,7 +10,7 @@ more details. """ -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from devices import DeviceShadow from integrations import PluginIntegration from integrations.mapping_strategies import ( diff --git a/src/integrations/plugin/fl/flex.py b/src/integrations/plugin/fl/flex.py index 09bccbe6..bc47a465 100644 --- a/src/integrations/plugin/fl/flex.py +++ b/src/integrations/plugin/fl/flex.py @@ -10,7 +10,7 @@ more details. """ from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from control_surfaces import Ambient from devices import DeviceShadow from integrations import PluginIntegration diff --git a/src/integrations/plugin/fl/fpc.py b/src/integrations/plugin/fl/fpc.py index 3a040f5e..28f47faf 100644 --- a/src/integrations/plugin/fl/fpc.py +++ b/src/integrations/plugin/fl/fpc.py @@ -11,7 +11,7 @@ """ from typing import Any from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import GeneratorIndex, FlIndex from common.util.grid_mapper import GridCell from control_surfaces import Note diff --git a/src/integrations/plugin/fl/parametric_eq.py b/src/integrations/plugin/fl/parametric_eq.py index 429dfebe..f8d5672b 100644 --- a/src/integrations/plugin/fl/parametric_eq.py +++ b/src/integrations/plugin/fl/parametric_eq.py @@ -10,7 +10,7 @@ from typing import Any from common.param import Param from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import GeneratorIndex from control_surfaces import Fader, Knob, Encoder from control_surfaces import ControlShadowEvent diff --git a/src/integrations/plugin/fl/slicers.py b/src/integrations/plugin/fl/slicers.py index 30591637..08727f66 100644 --- a/src/integrations/plugin/fl/slicers.py +++ b/src/integrations/plugin/fl/slicers.py @@ -10,7 +10,7 @@ more details. """ from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import GeneratorIndex from common.util.grid_mapper import GridCell from devices import DeviceShadow diff --git a/src/integrations/plugin/klevgrand/daw_cassette.py b/src/integrations/plugin/klevgrand/daw_cassette.py index 003e3ae5..e8a9c3b4 100644 --- a/src/integrations/plugin/klevgrand/daw_cassette.py +++ b/src/integrations/plugin/klevgrand/daw_cassette.py @@ -11,7 +11,7 @@ """ from common.param import Param from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import EffectIndex from common.util.grid_mapper import GridCell from control_surfaces import ControlShadowEvent, ControlShadow diff --git a/src/integrations/plugin/spitfire/spitfire_generic.py b/src/integrations/plugin/spitfire/spitfire_generic.py index 20618aaa..435280df 100644 --- a/src/integrations/plugin/spitfire/spitfire_generic.py +++ b/src/integrations/plugin/spitfire/spitfire_generic.py @@ -14,7 +14,7 @@ from common.param import Param from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import GeneratorIndex from common.util.grid_mapper import GridCell from control_surfaces import ControlShadowEvent diff --git a/src/integrations/window/browser.py b/src/integrations/window/browser.py index 87e8a816..3add306e 100644 --- a/src/integrations/window/browser.py +++ b/src/integrations/window/browser.py @@ -9,7 +9,7 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes.window import WindowIndex from devices import DeviceShadow from integrations import WindowIntegration diff --git a/src/integrations/window/channel_rack/plug.py b/src/integrations/window/channel_rack/plug.py index c6a99e00..fb281916 100644 --- a/src/integrations/window/channel_rack/plug.py +++ b/src/integrations/window/channel_rack/plug.py @@ -9,7 +9,7 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import WindowIndex from common.types import Color from devices import DeviceShadow diff --git a/src/integrations/window/mixer.py b/src/integrations/window/mixer.py index 7c3743d6..035e4f8d 100644 --- a/src/integrations/window/mixer.py +++ b/src/integrations/window/mixer.py @@ -15,7 +15,7 @@ from common import getContext from common.tracks.mixer_track import MixerTrack from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import WindowIndex from common.util.api_fixes import ( getSelectedDockMixerTracks, diff --git a/src/integrations/window/piano_roll.py b/src/integrations/window/piano_roll.py index 767946a8..96d3fbca 100644 --- a/src/integrations/window/piano_roll.py +++ b/src/integrations/window/piano_roll.py @@ -11,7 +11,7 @@ """ import transport import ui -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import WindowIndex from common.types import Color from devices import DeviceShadow diff --git a/src/integrations/window/playlist.py b/src/integrations/window/playlist.py index a617f1c5..faae314b 100644 --- a/src/integrations/window/playlist.py +++ b/src/integrations/window/playlist.py @@ -19,7 +19,7 @@ from common import getContext from common.tracks import PlaylistTrack from common.types import Color -from common.extension_manager import ExtensionManager +from common.__old_extension_manager import ExtensionManager from common.plug_indexes import WindowIndex, FlIndex from common.util.api_fixes import getFirstPlaylistSelection from control_surfaces import consts From ce1f1f3dfa972a24f201b496a925a6fa82f990cd Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sun, 3 Dec 2023 00:11:53 +1100 Subject: [PATCH 10/15] Refactor settings to be type-safe --- src/common/config/__init__.py | 11 ++ src/common/config/default_config.py | 39 +++++ src/common/config/loader.py | 44 ++++++ src/common/config/types/__init__.py | 26 ++++ src/common/config/types/advanced.py | 52 +++++++ src/common/config/types/controls.py | 57 +++++++ src/common/config/types/integrations.py | 26 ++++ src/common/default_config.py | 116 -------------- src/common/settings.py | 163 -------------------- src/common/util/dict_tools.py | 50 +++--- src/devices/device_shadow.py | 8 +- src/ucs_config/{config.py => ucs_config.py} | 0 tests/util_test.py | 10 +- 13 files changed, 293 insertions(+), 309 deletions(-) create mode 100644 src/common/config/__init__.py create mode 100644 src/common/config/default_config.py create mode 100644 src/common/config/loader.py create mode 100644 src/common/config/types/__init__.py create mode 100644 src/common/config/types/advanced.py create mode 100644 src/common/config/types/controls.py create mode 100644 src/common/config/types/integrations.py delete mode 100644 src/common/default_config.py delete mode 100644 src/common/settings.py rename src/ucs_config/{config.py => ucs_config.py} (100%) diff --git a/src/common/config/__init__.py b/src/common/config/__init__.py new file mode 100644 index 00000000..0143f419 --- /dev/null +++ b/src/common/config/__init__.py @@ -0,0 +1,11 @@ +""" +common > config + +Code for managing the configuration of the script. + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" diff --git a/src/common/config/default_config.py b/src/common/config/default_config.py new file mode 100644 index 00000000..891056ac --- /dev/null +++ b/src/common/config/default_config.py @@ -0,0 +1,39 @@ +""" +common > default_config + +Stores the default configuration for the script. The user configuration will +override any existing settings here. + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" +from .types import Config + +DEFAULT_CONFIG: Config = { + "controls": { + "double_press_time": 0.3, + "long_press_time": 0.5, + "short_press_time": 0.1, + "navigation_speed": 5, + "use_snap": True, + "use_undo_toggle": True, + "score_log_dump_length": 120 + }, + "integrations": { + "mixer": { + "allow_extended_volume": False + }, + }, + "advanced": { + "debug": { + "profiling": False, + "exec_tracing": False + }, + "drop_tick_time": 100, + "slow_tick_time": 50, + "activity_history_length": 25, + }, +} diff --git a/src/common/config/loader.py b/src/common/config/loader.py new file mode 100644 index 00000000..7e2b4ed0 --- /dev/null +++ b/src/common/config/loader.py @@ -0,0 +1,44 @@ +""" +common > config > loader + +Code for loading the user configuration. +""" +import sys +from .types import Config +from .default_config import DEFAULT_CONFIG +from common.util import dict_tools + + +# Add the custom configuration directory to the path so we can load from it +scripts_dir = \ + '/'.join(__file__.replace('\\', '/').split('/')[:-4]) + '/ucs_config' +sys.path.insert(0, scripts_dir) + + +def load_configuration() -> Config: + """ + Load the script configuration + """ + try: + from ucs_config import CONFIG as user_settings + except ImportError as e: + # The file doesn't exist + # FIXME: Log the error properly + print(f"User configuration not found {e}") + return DEFAULT_CONFIG + except SyntaxError as e: + # FIXME: Log the error properly + print(e) + return DEFAULT_CONFIG + except Exception as e: + # FIXME: Log the error properly + print(e) + return DEFAULT_CONFIG + + # Now merge the user settings with the defaults + merged_settings = dict_tools.recursive_merge_dictionaries( + DEFAULT_CONFIG, + user_settings, + ) + + return merged_settings diff --git a/src/common/config/types/__init__.py b/src/common/config/types/__init__.py new file mode 100644 index 00000000..b78a3ad4 --- /dev/null +++ b/src/common/config/types/__init__.py @@ -0,0 +1,26 @@ +from typing import TypedDict + +from .advanced import AdvancedConfig +from .controls import ControlSurfaceConfig +from .integrations import IntegrationConfig + + +class Config(TypedDict): + """ + Represents the configuration of the script + """ + + controls: ControlSurfaceConfig + """ + Options for control surfaces, applied to all devices + """ + + integrations: IntegrationConfig + """ + Options for integrations + """ + + advanced: AdvancedConfig + """ + Advanced configuration options, including debug options + """ diff --git a/src/common/config/types/advanced.py b/src/common/config/types/advanced.py new file mode 100644 index 00000000..a30080d9 --- /dev/null +++ b/src/common/config/types/advanced.py @@ -0,0 +1,52 @@ +from typing import TypedDict + + +class DebugConfig(TypedDict): + """ + Settings used for debugging + """ + + profiling_enabled: bool + """Whether performance profiling should be enabled""" + + exec_tracing_enabled: bool + """ + Whether profiling should print the tracing of profiler contexts + within the script. Useful for troubleshooting crashes in FL Studio's + MIDI API. Requires profiling to be enabled. + + Note that this causes a huge amount of output to be produced on the + console, which can be immensely laggy. Only use it if absolutely necessary. + """ + + +class AdvancedConfig(TypedDict): + """ + Advanced settings for the script. Don't edit these unless you know what + you're doing, as they could cause the script to break, or behave badly. + """ + + debug: DebugConfig + """ + Settings used for debugging the script + """ + + drop_tick_time: int + """ + Time in ms during which we expect the script to be ticked. If the + script doesn't tick during this time, then the script will consider + itself to be constrained by performance, and will drop the next tick + to prevent lag in FL Studio. + """ + + slow_tick_time: int + """ + Time in ms for which a tick should be expected to complete. If + ticking FL Studio takes longer than this, it will be recorded, + regardless of whether profiling is enabled. + """ + + activity_history_length: int + """ + The maximum length of the plugin/window tracking history + """ diff --git a/src/common/config/types/controls.py b/src/common/config/types/controls.py new file mode 100644 index 00000000..de349adb --- /dev/null +++ b/src/common/config/types/controls.py @@ -0,0 +1,57 @@ +from typing import TypedDict + + +class ControlSurfaceConfig(TypedDict): + """ + Configuration of options for control surfaces + """ + + double_press_time: float + """The time in seconds for which a double press is valid""" + + long_press_time: float + """ + The time in seconds required to register a long press + + NOTE: This is currently not implemented, but will be used to implement + things such as long press scrolling. + """ + + short_press_time: float + """ + The maximum duration in seconds for which a button press is considered + short. + + This is used for buttons that usually repeat over time, but have a + different action is pressed and released quickly. + """ + + navigation_speed: int + """ + How fast to navigate when long-pressing a direction button. This roughly + corresponds to the number of times the action will be performed per second. + """ + + use_snap: bool + """ + Whether values that have a centred default value (eg panning) should snap + to the centred value when values are close. + """ + + use_undo_toggle: bool + """ + Whether the undo/redo button should act as a toggle between undo and redo. + + * If `True`, it will redo unless there is nothing to redo, in which case it + will undo + * If `False`, it will always undo + + Note that devices with separate undo and redo buttons are not affected by + this option. + """ + + score_log_dump_length: int + """ + The length of time to dump to a pattern from the score log when a + capture MIDI button is pressed, in seconds. + """ diff --git a/src/common/config/types/integrations.py b/src/common/config/types/integrations.py new file mode 100644 index 00000000..011066f2 --- /dev/null +++ b/src/common/config/types/integrations.py @@ -0,0 +1,26 @@ +from typing import TypedDict + + +class MixerConfig(TypedDict): + """ + Configuration for FL Studio mixer + """ + + allow_extended_volume: bool + """ + Whether volumes over 100% should be allowed + * `True`: faders will map from 0-125% + * `False`: faders will map from 0-100% + """ + + +class IntegrationConfig(TypedDict): + """ + Configuration of script integrations - any integrations that provide + settings are listed as members of this. + """ + + mixer: MixerConfig + """ + FL Studio mixer window + """ diff --git a/src/common/default_config.py b/src/common/default_config.py deleted file mode 100644 index d7cf57b5..00000000 --- a/src/common/default_config.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -common > default_config - -Stores the default configuration for the script. The user configuration will -override any existing settings here. - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -from .logger import verbosity - -CONFIG = { - # Settings to configure for controllers - "controls": { - # The time in seconds for which a double press is valid - "double_press_time": 0.3, - # The time in seconds required to register a long press - "long_press_time": 0.5, - # The maximum time in seconds required to register a short press - "short_press_time": 0.1, - # How fast to navigate when long pressing a button, lower is faster - "navigation_speed": 3, - # Whether an undo/redo button should always undo, rather than acting as - # an undo/redo toggle - "disable_undo_toggle": False, - }, - # Settings to configure plugins - "plugins": { - # General configuration - "general": { - # Whether values that have a centred default should snap close - # values to the default - "do_snap": True, - # The length of time to dump to a pattern from the score log when a - # capture MIDI button is pressed, in seconds. - "score_log_dump_length": 120 - }, - # FL Studio mixer - "mixer": { - # Whether volumes over 100% should be allowed - # * True: faders will map from 0-125% - # * False: faders will map from 0-100% - "allow_extended_volume": False - }, - }, - # Settings used during script initialization - "bootstrap": { - # Whether to skip sending sysex messages when attempting to recognize - # devices... will improve startup time for devices that don't support - # universal device enquiries, but will result in other devices - # breaking. Enabling this is not recommended. - "skip_enquiry": False, - # Whether sending the device enquiry message should be delayed - # until after initialization (workaround for a bug in FL 20.9.1) - "delay_enquiry": True, - # How long to wait after sending a universal device enquiry until the - # fallback device recognition method is used, in seconds. - "detection_timeout": 3.0, - # Associations between device name (as shown in FL Studio) and device - # id to register (listed in class under getId() function) - # This can be used to skip using universal device enquiry messages, if - # necessary - "name_associations": [ - # For example: - # ("my device name", "Manufacturer.Model.Mark.Variant") - ], - }, - # Settings used for debugging - "debug": { - # Whether performance profiling should be enabled - "profiling": False, - # Whether profiling should print the tracing of profiler contexts - # within the script. Useful for troubleshooting crashes in FL Studio's - # MIDI API. Requires profiling to be enabled. - "exec_tracing": False - }, - # Logging settings - "logger": { - # Verbosity for which full details will be printed to the console when - # it is logged. - "critical_verbosity": verbosity.ERROR, - # Maximum verbosity for which all logged messages will be printed - "max_verbosity": verbosity.WARNING, - # Categories to watch, meaning they will be printed, even if a lower - # verbosity is used. For details on available categories, refer to - # common/logger/log_hierarchy.py. - "watched_categories": [ - "general" - ], - # Maximum verbosity for which watched categories of logged messages - # will be printed - "max_watched_verbosity": verbosity.INFO, - # Verbosity levels at or above this will be discarded entirely be the - # logger to improve performance - "discard_verbosity": verbosity.NOTE, - }, - # Advanced settings for the script. Don't edit these unless you know what - # you're doing, as they could cause the script to break, or behave badly. - "advanced": { - # Time in ms during which we expect the script to be ticked. If the - # script doesn't tick during this time, then the script will consider - # itself to be constrained by performance, and will drop the next tick - # to prevent lag in FL Studio. - "drop_tick_time": 100, - # Time in ms for which a tick should be expected to complete. If - # ticking FL Studio takes longer than this, it will be recorded, - # regardless of whether profiling is enabled. - "slow_tick_time": 50, - # The maximum length of the plugin/window tracking history - "activity_history_length": 25, - }, -} diff --git a/src/common/settings.py b/src/common/settings.py deleted file mode 100644 index 868b31e3..00000000 --- a/src/common/settings.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -common > settings - -Contains a wrapper class for the settings, allowing for an effective way to -check the configuration of the script. - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -__all__ = [ - 'Settings' -] - -import sys -from typing import Any -from .util import dict_tools -from . import default_config as d -from .exceptions import InvalidConfigError - -# Load the main config -scripts_dir = \ - '/'.join(__file__.replace('\\', '/').split('/')[:-3]) + '/ucs_config' -sys.path.append(scripts_dir) - -had_errors = False -config_errors = '' -try: - from config import CONFIG # type: ignore -except ImportError: - # Failed to import - assume they don't have custom settings - CONFIG = {} -except SyntaxError as e: - CONFIG = {} - had_errors = True - config_errors = f'Syntax error: {e}' -except Exception as e: - CONFIG = {} - had_errors = True - config_errors = f'Unknown error: {e}' - - -class Settings: - """ - A container for the configuration of the script - - Used to avoid having to deal with the awfulness of pulling things out of - dictionaries. Also manages the differences between the default config and - any user modifications. - """ - - def __init__(self) -> None: - """ - Initialize and load the script's settings - """ - self.__valid = True - self.__error_msg = '' - if had_errors: - self.__valid = False - self.__error_msg = config_errors - self._settings_dict = d.CONFIG - return - try: - config = dict_tools.expandDictShorthand(CONFIG) - self._settings_dict = dict_tools.recursiveMergeDictionaries( - d.CONFIG, config) - except (KeyError, TypeError) as e: - self.__valid = False - self.__error_msg = str(e) - self._settings_dict = d.CONFIG - - def assert_loaded(self) -> None: - """ - Raise an exception if settings didn't load correctly - - ### Raises: - * `InvalidConfigError`: invalid config - """ - if not self.__valid: - raise InvalidConfigError(self.__error_msg) - - @staticmethod - def _recursiveGet(keys: list[str], settings: dict) -> Any: - """ - Recursive function for getting settings value - - ### Args: - * `keys` (`list[str]`): list of keys - * `settings` (`dict`): settings dictionary to search - - ### Returns: - * any: value - """ - if len(keys) == 1: - return settings[keys[0]] - else: - return Settings._recursiveGet(keys[1:], settings[keys[0]]) - - @staticmethod - def _recursiveSet(keys: list[str], settings: dict, value: Any) -> None: - """ - Recursive function for setting settings value - - ### Args: - * `keys` (`list[str]`): list of keys - * `settings` (`dict`): settings dictionary to search - * `value` (`Any`): value to set - """ - if len(keys) == 1: - if keys[0] not in settings: - raise KeyError(keys[0]) - settings[keys[0]] = value - else: - Settings._recursiveSet(keys[1:], settings[keys[0]], value) - - def get(self, key: str) -> Any: - """ - Get an entry in the settings - - ### Args: - * `key` (`str`): key to access settings from - - ### Raises: - * `KeyError`: Unable to find settings - - ### Returns: - * `Any`: Value - """ - try: - return Settings._recursiveGet(key.split('.'), self._settings_dict) - except KeyError as e: - raise KeyError( - f"Unable to find setting at '{key}'. Failed for key {e}" - ) from None - except IndexError: - raise KeyError(f"Unable to find setting at '{key}'") from None - - def set(self, key: str, value: Any) -> None: - """ - Set an entry in the settings - - ### Args: - * `key` (`str`): key to access settings from - * `value` (`Any`): value to set - - ### Raises: - * `KeyError`: Unable to find settings - """ - try: - return Settings._recursiveSet( - key.split('.'), - self._settings_dict, - value - ) - except KeyError as e: - raise KeyError( - f"Unable to find setting at '{key}'. Failed for key {e}" - ) from None - except IndexError: - raise KeyError(f"Unable to find setting at '{key}'") from None diff --git a/src/common/util/dict_tools.py b/src/common/util/dict_tools.py index 4ab5c9db..fdffbd3b 100644 --- a/src/common/util/dict_tools.py +++ b/src/common/util/dict_tools.py @@ -11,7 +11,6 @@ """ from typing import Any, Protocol, TypeVar -# from copy import deepcopy class SupportsComparison(Protocol): @@ -40,13 +39,14 @@ def __le__(self, __other: Any) -> bool: ... -K = TypeVar("K") -V = TypeVar("V", bound=SupportsComparison) +BaseDict = TypeVar('BaseDict', bound=dict) -def recursiveMergeDictionaries( - ref: dict, override: dict, path: str = '' -) -> dict: +def recursive_merge_dictionaries( + base: BaseDict, + override: dict, + path: str = '', +) -> BaseDict: """ Merge the contents of two nested dictionaries, ensuring all values in second override existing values in the first @@ -71,7 +71,7 @@ def recursiveMergeDictionaries( # Get a copy of the dictionary # Note that a deep copy isn't necessary as nested contents will be copied # when we recurse, effectively making a manual deep copy - new = ref.copy() + new = base.copy() for key, value in override.items(): # Check for invalid settings value @@ -79,21 +79,25 @@ def recursiveMergeDictionaries( key_path = key else: key_path = path + '.' + key - if key not in ref: + if key not in base: raise KeyError(f"{ERROR_HEADER}: `{key_path}` is " f"not a valid settings value") - ref_value = ref[key] + base_value = base[key] # If it's a dictionary, we should recurse and copy those settings # Using `type(x) is dict` so that a different inherited type can be # used to specify when an actual settings value is of type dictionary - if type(ref_value) is dict: + if type(base_value) is dict: # But first, make sure that we're given the correct type of setting if type(value) is not dict: - raise TypeError(f"{ERROR_HEADER}: expected a category at " + raise TypeError(f"{ERROR_HEADER}: expected a dictionary at " f"`{key_path}`, not a value") # Recurse and merge the result - new[key] = recursiveMergeDictionaries(ref_value, value, key_path) + new[key] = recursive_merge_dictionaries( + base_value, + value, + key_path, + ) # Otherwise, we should set the value directly, by creating a copy else: @@ -102,9 +106,9 @@ def recursiveMergeDictionaries( # actual value. This ensures that if we have a settings value that # is literally a dictionary, it won't cause it to fail when the # user uses the simple `dict` type. - if not isinstance(ref_value, type(value)): + if not isinstance(base_value, type(value)): raise TypeError(f"{ERROR_HEADER}: expected a value of type " - f"{type(ref_value)} for settings value at " + f"{type(base_value)} for settings value at " f"`{key_path}`") new[key] = value # deepcopy(value) @@ -112,7 +116,7 @@ def recursiveMergeDictionaries( return new -def dictKeyRecursiveInsert( +def dict_key_recursive_insert( d: dict, keys: list[str], val: Any, key_full: str ) -> None: """ @@ -135,10 +139,10 @@ def dictKeyRecursiveInsert( return if keys[0] not in d: d[keys[0]] = {} - dictKeyRecursiveInsert(d[keys[0]], keys[1:], val, key_full) + dict_key_recursive_insert(d[keys[0]], keys[1:], val, key_full) -def expandDictShorthand(d: dict[str, Any], path: str = '') -> dict: +def expand_dict_shorthand(d: dict[str, Any], path: str = '') -> dict: """ Recursively expands short-hand notation for dictionary data @@ -178,15 +182,19 @@ def expandDictShorthand(d: dict[str, Any], path: str = '') -> dict: full_key = path + '.' + key # If it's a dict, make sure it is expanded as well if isinstance(value, dict): - value = expandDictShorthand(value, full_key) + value = expand_dict_shorthand(value, full_key) # Expand the path and insert it in the correct location split_path = key.split('.') - dictKeyRecursiveInsert(new, split_path, value, full_key) + dict_key_recursive_insert(new, split_path, value, full_key) return new -def greatestKey(d: dict[K, V]) -> K: +K = TypeVar("K") +V = TypeVar("V", bound=SupportsComparison) + + +def greatest_key(d: dict[K, V]) -> K: """ Returns the key which maps to the greatest value @@ -212,7 +220,7 @@ def greatestKey(d: dict[K, V]) -> K: return highest -def lowestValueGrEqTarget(d: dict[K, V], target: V) -> K: +def lowest_value_greater_eq_target(d: dict[K, V], target: V) -> K: """ Returns the key which maps to the lowest value that is still above the threshold value. diff --git a/src/devices/device_shadow.py b/src/devices/device_shadow.py index 2522f75d..ce1376b5 100644 --- a/src/devices/device_shadow.py +++ b/src/devices/device_shadow.py @@ -15,7 +15,7 @@ from typing_extensions import TypeAlias from common.plug_indexes import FlIndex -from common.util.dict_tools import lowestValueGrEqTarget, greatestKey +from common.util.dict_tools import lowest_value_greater_eq_target, greatest_key from control_surfaces import ControlSurface from . import Device @@ -228,17 +228,17 @@ def _getMatches( try: if target_num is None: - highest = greatestKey(num_type_matches) + highest = greatest_key(num_type_matches) else: try: # Find the lowest value above the allowed amount - highest = lowestValueGrEqTarget( + highest = lowest_value_greater_eq_target( num_type_matches, target_num ) except ValueError: # If that fails, just use the highest value available - highest = greatestKey(num_type_matches) + highest = greatest_key(num_type_matches) except ValueError: # No matches causes greatestKey() to fail since there's no keys return [] diff --git a/src/ucs_config/config.py b/src/ucs_config/ucs_config.py similarity index 100% rename from src/ucs_config/config.py rename to src/ucs_config/ucs_config.py diff --git a/tests/util_test.py b/tests/util_test.py index 66133da6..878a0a99 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -11,8 +11,8 @@ """ from common.util.dict_tools import ( - recursiveMergeDictionaries, - expandDictShorthand + recursive_merge_dictionaries, + expand_dict_shorthand ) from common.util.snap import snap @@ -34,7 +34,7 @@ def test_expand_dict_shorthand(): "c": 4 } } - assert expandDictShorthand(t) == exp + assert expand_dict_shorthand(t) == exp def test_expand_dict_shorthand_complex(): @@ -58,7 +58,7 @@ def test_expand_dict_shorthand_complex(): "c": 4 } } - assert expandDictShorthand(t) == exp + assert expand_dict_shorthand(t) == exp def test_recursive_merge_simple(): @@ -82,7 +82,7 @@ def test_recursive_merge_simple(): }, "d": 4 } - assert recursiveMergeDictionaries(ref, over) == exp + assert recursive_merge_dictionaries(ref, over) == exp def test_snap(): From b61149f83a54d996c58efeaa3f1f7323501e9322 Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Mon, 4 Dec 2023 01:13:51 +1100 Subject: [PATCH 11/15] Rewrite overall script context --- src/common/config/__init__.py | 3 + src/common/config/default_config.py | 4 +- src/common/context_manager.py | 355 ---------------------------- src/common/ucs_context.py | 181 ++++++++++++++ src/common/util/dict_tools.py | 4 +- 5 files changed, 188 insertions(+), 359 deletions(-) delete mode 100644 src/common/context_manager.py create mode 100644 src/common/ucs_context.py diff --git a/src/common/config/__init__.py b/src/common/config/__init__.py index 0143f419..8fe5e7d7 100644 --- a/src/common/config/__init__.py +++ b/src/common/config/__init__.py @@ -9,3 +9,6 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ +__all__ = ['load_configuration'] + +from .loader import load_configuration diff --git a/src/common/config/default_config.py b/src/common/config/default_config.py index 891056ac..66e07de5 100644 --- a/src/common/config/default_config.py +++ b/src/common/config/default_config.py @@ -29,8 +29,8 @@ }, "advanced": { "debug": { - "profiling": False, - "exec_tracing": False + "profiling_enabled": False, + "exec_tracing_enabled": False }, "drop_tick_time": 100, "slow_tick_time": 50, diff --git a/src/common/context_manager.py b/src/common/context_manager.py deleted file mode 100644 index ecd74240..00000000 --- a/src/common/context_manager.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -common > context_manager - -Contains the DeviceContextManager class, used to manage the state of the -script, allowing for soft resets of the script when required. - -Authors: -* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] - -This code is licensed under the GPL v3 license. Refer to the LICENSE file for -more details. -""" - -__all__ = [ - 'catchContextResetException', - 'getContext', - 'resetContext', - 'unsafeResetContext' -] - -from .profiler import profilerDecoration -from . import logger -from typing import NoReturn, Optional, Callable, TYPE_CHECKING -from time import time_ns -from fl_classes import FlMidiMsg - -from .settings import Settings -from .activity_state import ActivityState -from .exceptions import UcsError -from .util.api_fixes import catchUnsafeOperation -from .util.misc import NoneNoPrintout -from .util.events import isEventForwarded, isEventForwardedHere -from .util.catch_exception_decorator import catchExceptionDecorator -from .profiler import ProfilerManager - -from .states import ( - IScriptState, - ErrorState, - StateChangeException, -) - -if TYPE_CHECKING: - from devices import Device - - -def toErrorState(err: UcsError): - getContext().setState(ErrorState(err)) - - -class DeviceContextManager: - """Defines the context for the entire script, which allows the modular - components of script to be dynamically refreshed and reloaded, as well as - be reset to the default start-up state if required. - - It is gettable from any location by using the getContext() method - """ - - def __init__(self) -> None: - """Initialize the context manager, including reloading any required - modules - """ - self.settings = Settings() - self.activity = ActivityState() - # Set the state of the script to wait for the device to be recognized - self.state: Optional[IScriptState] = None - if self.settings.get("debug.profiling"): - trace = self.settings.get("debug.exec_tracing") - self.profiler: Optional[ProfilerManager] = ProfilerManager(trace) - else: - self.profiler = None - # Time the script last ticked at - self._last_tick = time_ns() - self._ticks = 0 - self._dropped_ticks = 0 - self._slow_ticks = 0 - self._device: Optional['Device'] = None - - def enableProfiler(self, trace: bool = False) -> None: - """ - Enable the performance profiler - - ### Args: - * `trace` (`bool`, optional): Whether to print traces. Defaults to - `False`. - """ - self.profiler = ProfilerManager(trace) - - @catchExceptionDecorator(StateChangeException) - @catchExceptionDecorator(UcsError, toErrorState) - @profilerDecoration("initialize") - def initialize(self, state: IScriptState) -> None: - """Initialize the controller associated with this context manager. - - ### Args: - * `state` (`IScriptState`): state to initialize with - """ - # Ensure settings are valid - self.settings.assert_loaded() - self.state = state - state.initialize() - - @catchExceptionDecorator(StateChangeException) - @catchExceptionDecorator(UcsError, toErrorState) - @profilerDecoration("deinitialize") - def deinitialize(self) -> None: - """Deinitialize the controller when FL Studio closes or begins a render - """ - if self._device is not None: - self._device.deinitialize() - self._device = None - if self.state is not None: - self.state.deinitialize() - self.state = None - - @catchUnsafeOperation - @catchExceptionDecorator(StateChangeException) - @catchExceptionDecorator(UcsError, toErrorState) - @profilerDecoration("processEvent") - def processEvent(self, event: FlMidiMsg) -> None: - """Process a MIDI event - - ### Args: - * `event` (`event`): event to process - """ - # Filter out events that shouldn't be forwarded here - if isEventForwarded(event): - # If device is none, ignore all forwarded messages - if self._device is None or not isEventForwardedHere(event): - event.handled = True - return - if self.state is None: - raise MissingContextException("State not set") - self.state.processEvent(event) - - @catchUnsafeOperation - @catchExceptionDecorator(StateChangeException) - @catchExceptionDecorator(UcsError, toErrorState) - @profilerDecoration("tick") - def tick(self) -> None: - """ - Called frequently to allow any required updates to the controller - """ - if self.state is None: - raise MissingContextException("State not set") - # Update number of ticks - self._ticks += 1 - # If the last tick was over 60 ms ago, then our script is getting laggy - # Skip this tick to compensate - last_tick = self._last_tick - self._last_tick = time_ns() - drop_tick_time = self.settings.get("advanced.drop_tick_time") - if (self._last_tick - last_tick) / 1_000_000 > drop_tick_time: - self._dropped_ticks += 1 - return - tick_start = time_ns() - # Tick active plugin - self.activity.tick() - # Tick the current script state - self.state.tick() - tick_end = time_ns() - slow_tick_time = self.settings.get("advanced.slow_tick_time") - if (tick_end - tick_start) / 1_000_000 > slow_tick_time: - self._slow_ticks += 1 - - def getTickNumber(self) -> int: - """ - Returns the tick number of the script - - This is the number of times the script has been ticked - - ### Returns: - * `int`: tick number - """ - return self._ticks - - def getDroppedTicks(self) -> str: - """ - Returns the number of ticks dropped by the controller - - This is indicative of FL Studio performance - - ### Returns: - * `str`: info on dropped ticks - """ - percent = int(self._dropped_ticks / self._ticks * 100) - return f"{self._dropped_ticks} dropped ticks ({percent}%)" - - def getSlowTicks(self) -> str: - """ - Returns the number of ticks that ran slowly - - This is indicative of script performance - - ### Returns: - * `str`: info on dropped ticks - """ - percent = int(self._slow_ticks / self._ticks * 100) - return f"{self._slow_ticks} slow ticks ({percent}%)" - - def setState(self, new_state: IScriptState) -> NoReturn: - """ - Set the state of the script to a new state - - This is used to transfer between different states - - ### Args: - * `new_state` (`IScriptState`): state to switch to - - ### Raises: - * `StateChangeException`: state changed successfully - """ - self.state = new_state - new_state.initialize() - raise StateChangeException("State changed") - - def registerDevice(self, dev: 'Device'): - """ - Register a recognized device - - ### Args: - * `dev` (`Device`): device number - """ - self._device = dev - - def getDevice(self) -> 'Device': - """ - Return a reference to the recognized device - - This is used so that forwarded events can be encoded correctly - - ### Raises: - * `ValueError`: device not set - - ### Returns: - * `Device`: device - """ - if self._device is None: - raise ValueError("Device not set") - return self._device - - def getDeviceId(self) -> str: - """ - Return the type of device that's been recognized - - This can be used to ensure that devices are recognized correctly - - ### Returns: - * `str`: device type - """ - if self._device is not None: - return self._device.getId() - else: - return 'Device not recognized' - - -class ContextResetException(Exception): - """ - Raised when the context is reset, so as to prevent any other operations - using the old context from succeeding - """ - - -class MissingContextException(Exception): - """ - Raised when the context hasn't been initialized yet - """ - - -def catchContextResetException(func: Callable) -> Callable: - """A decorator for catching ContextResetExceptions so that the program - continues normally - - ### Args: - * `func` (`Callable`): function to decorate - - ### Returns: - * `Callable`: decorated function - """ - def wrapper(*args, **kwargs): - try: - ret = func(*args, **kwargs) - if ret is None: - return NoneNoPrintout - except ContextResetException: - return NoneNoPrintout - return wrapper - - -# The context manager's instance -# This should be the only non-constant global variable in the entire program, -# except for the log -_context: Optional[DeviceContextManager] = None - - -def getContext() -> DeviceContextManager: - """Returns a reference to the device context - - ### Raises: - * `Exception`: when the context is `None`, indicating that it wasn't - initialized - - ### Returns: - * `DeviceContextManager`: context - """ - if _context is None: - raise Exception("Context isn't initialized") - - return _context - - -def resetContext(reason: str = "none") -> NoReturn: - """Resets the context of the script to the default, before raising a - ContextResetException to halt the current event - - ### Args: - * `reason` (`str`, optional): reason for resetting. Defaults to "none". - - ### Raises: - * `ContextResetException`: halt the event's processing - """ - global _context - logger.log( - "bootstrap.context.reset", - f"Device context reset with reason: {reason}", - logger.verbosity.WARNING) - _context = DeviceContextManager() - raise ContextResetException(reason) - - -@catchContextResetException -def unsafeResetContext(reason: str = "none") -> None: - """ - Reset the context of the script to the default, without raising a - ContextResetException to halt the current event. - - WARNING: Calling this inside the main components of the script is a very - bad idea, as your code will interact with a context it isn't prepared for, - leading to undefined behavior. This should only ever be called by the user - through the console. - - ### Args: - * `reason` (`str`, optional): Reason for reset. Defaults to `"none"`. - """ - resetContext(reason) - - -def _initContext() -> None: - """ - Initializes the context manager for the script - """ - global _context - _context = DeviceContextManager() - - -_initContext() diff --git a/src/common/ucs_context.py b/src/common/ucs_context.py new file mode 100644 index 00000000..95a9fac5 --- /dev/null +++ b/src/common/ucs_context.py @@ -0,0 +1,181 @@ +""" +common > ucs_context + +Defines the overall context for the script, representing almost all of the +script's state. + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" +from fl_classes import FlMidiMsg +from time import time_ns +from typing import TYPE_CHECKING, Optional + +from .extensions.integrations import ( + get_core_preprocess_integrations, + get_core_postprocess_integrations, + get_integration_for_plugin, + get_integration_for_window, +) +from .config import load_configuration +from .activity_state import ActivityState +from .profiler import ProfilerManager +from .plug_indexes import WindowIndex +from .util.api_fixes import catchUnsafeOperation +from .util.misc import NoneNoPrintout + +if TYPE_CHECKING: + from devices import Device + from integrations import Integration + + +class UcsContext: + """ + The overall context for the script, representing most of its state. + """ + + def __init__(self, device: 'Device') -> None: + """ + Initialise the script's context + """ + self.initialized = False + """Whether the script is currently initialized and running""" + self.device = device + """The object representing the overall device""" + self.settings = load_configuration() + """The configuration of the script""" + self.activity = ActivityState() + """Maintains the currently selected plugin or window""" + + # FIXME: Make profiler detached from the context + + # Storing instances of all the integrations + + self.__plugin_integrations: dict[str, 'Integration'] = {} + """ + Mapping of plugin names to instances of the corresponding integration + objects + """ + + self.__window_integrations: dict[WindowIndex, 'Integration'] = {} + """ + Mapping of window indexes to instances of the corresponding integration + objects + """ + + self.__core_integrations: list['Integration'] = [] + """ + List of instances of the core script integrations + """ + + # Performance measuring + + self.__last_tick_time = time_ns() + """The last time that the script ticked""" + self.__tick_count = 0 + """The number of times the script has ticked since it was loaded""" + self.__dropped_ticks = 0 + """ + The number of times a tick was skipped due to the previous tick being + too slow. + """ + self.__slow_ticks = 0 + """ + The number of times a tick event has been processed slowly, used for + finding performance issues + """ + self.__slow_integrations: dict[str, int] = {} + """ + A mapping of slow integrations to the number of times they've caused + a slow tick + """ + + def initialize(self) -> None: + """ + Initialize the device + """ + self.device.initialize() + self.initialized = True + + def deinitialize(self) -> None: + """ + Deinitialize the device + """ + self.device.deinitialize() + self.initialized = False + + def process_event(self, event: FlMidiMsg) -> None: + """ + Process a MIDI event + + This handles the event by forwarding it to the active integrations + + ### Args + * `event` (`FlMidiMsg`): event to process + """ + if not self.initialized: + return + + # TODO: Process forwarded events here + + # TODO: write this + + @catchUnsafeOperation + def tick(self) -> None: + """ + Called frequently to let devices and integrations update. + """ + # If the script isn't initialized, performing a tick could be dangerous + # and get it into an invalid state + if not self.initialized: + return + + self.__tick_count += 1 + + # If the last tick was too long ago, then our script is getting laggy + # Skip this tick to compensate + last_tick = self.__last_tick_time + self.__last_tick_time = time_ns() + drop_tick_time = self.settings['advanced']['drop_tick_time'] + if (self.__last_tick_time - last_tick) / 1_000_000 > drop_tick_time: + self.__dropped_ticks += 1 + return + + tick_start_time = time_ns() + + self.activity.tick() + + # TODO: Tick the state + + tick_end_time = time_ns() + slow_tick_time = self.settings['advanced']['slow_tick_time'] + if (tick_end_time - tick_start_time) / 1_000_000 > slow_tick_time: + self.__slow_ticks += 1 + integration_name = self.activity.getActive().getName() + self.__slow_integrations[integration_name] = \ + self.__slow_integrations.get(integration_name, 0) + 1 + + def performance_info(self) -> NoneNoPrintout: + """ + Display information about the performance of the script. + """ + dropped_percent = int(self.__dropped_ticks / self._ticks * 100) + slow_percent = int(self.__slow_ticks / self._ticks * 100) + + print("=======================") + print("PERFORMANCE INFORMATION") + print(f"Tick count: {self.__tick_count}") + print(f"Dropped ticks: {self.__dropped_ticks} ({dropped_percent}%)") + print(f"Slow ticks: {self.__slow_ticks} ({slow_percent}%)") + print("Slow integrations:") + for integration, count in sorted( + self.__slow_integrations.items(), + key=lambda x: x[1], + ): + print(f" * {integration} ({count})") + print() + print("For more performance metrics, consider enabling the profiler") + return NoneNoPrintout diff --git a/src/common/util/dict_tools.py b/src/common/util/dict_tools.py index fdffbd3b..28e09898 100644 --- a/src/common/util/dict_tools.py +++ b/src/common/util/dict_tools.py @@ -10,7 +10,7 @@ more details. """ -from typing import Any, Protocol, TypeVar +from typing import Any, Protocol, TypeVar, TypedDict, Union class SupportsComparison(Protocol): @@ -39,7 +39,7 @@ def __le__(self, __other: Any) -> bool: ... -BaseDict = TypeVar('BaseDict', bound=dict) +BaseDict = TypeVar('BaseDict', bound=Union[dict, TypedDict]) def recursive_merge_dictionaries( From 248ce6895a8cc9adfb6c9fcc6d7bb9c851fb8fbc Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Fri, 8 Dec 2023 16:40:42 +1100 Subject: [PATCH 12/15] Refactor forwarded events system --- src/common/ucs_context.py | 5 +- src/common/util/events.py | 260 ---------------------------- src/common/util/forwarded_events.py | 144 +++++++++++++++ 3 files changed, 146 insertions(+), 263 deletions(-) create mode 100644 src/common/util/forwarded_events.py diff --git a/src/common/ucs_context.py b/src/common/ucs_context.py index 95a9fac5..8ad1338b 100644 --- a/src/common/ucs_context.py +++ b/src/common/ucs_context.py @@ -22,7 +22,6 @@ ) from .config import load_configuration from .activity_state import ActivityState -from .profiler import ProfilerManager from .plug_indexes import WindowIndex from .util.api_fixes import catchUnsafeOperation from .util.misc import NoneNoPrintout @@ -162,8 +161,8 @@ def performance_info(self) -> NoneNoPrintout: """ Display information about the performance of the script. """ - dropped_percent = int(self.__dropped_ticks / self._ticks * 100) - slow_percent = int(self.__slow_ticks / self._ticks * 100) + dropped_percent = int(self.__dropped_ticks / self.__tick_count * 100) + slow_percent = int(self.__slow_ticks / self.__tick_count * 100) print("=======================") print("PERFORMANCE INFORMATION") diff --git a/src/common/util/events.py b/src/common/util/events.py index e7f56c66..591cc692 100644 --- a/src/common/util/events.py +++ b/src/common/util/events.py @@ -9,267 +9,7 @@ This code is licensed under the GPL v3 license. Refer to the LICENSE file for more details. """ - -from typing import TYPE_CHECKING -import common -import device from fl_classes import FlMidiMsg, isMidiMsgStandard, isMidiMsgSysex -from common.exceptions import ( - EventEncodeError, - EventInspectError, - EventDecodeError, - EventDispatchError, -) - - -def getDeviceId() -> str: - """ - Get the identifier of a device - - ### Returns: - * `str`: device number of an auxiliary device - """ - return common.getContext().getDevice().getId() - - -def getDeviceNum() -> int: - """ - Determine the number of auxiliary devices that are connected using the - Universal Event Forwarder - - ### Returns: - * `int`: device number of an auxiliary device - """ - return common.getContext().getDevice().getDeviceNumber() - - -def isEventForwarded(event: FlMidiMsg) -> bool: - """ - Returns whether an event was forwarded from the Universal Event Forwarder - script - - Note that the event isn't necessarily directed towards this device - - ### Args: - * `event` (`FlMidiMsg`): event to check - - ### Returns: - * `bool`: whether it was forwarded - """ - # Check if the event is a forwarded one - # Look for 0xF0 and 0x7D - if not isMidiMsgSysex(event) \ - or not event.sysex.startswith(bytes([0xF0, 0x7D])): - return False - else: - return True - - -def getForwardedEventHeader() -> bytes: - """ - Returns a header for a forwarded event - - ### Returns: - * `bytes`: event header - """ - return bytes([ - 0xF0, # Start sysex - 0x7D # Non-commercial sysex ID - ]) + getDeviceId().encode() \ - + bytes([0]) - - -def encodeForwardedEvent(event: FlMidiMsg, device_num: int = -1) -> bytes: - """ - Encode an event such that it can be forwarded to the main script from - auxiliary scripts or to an auxiliary script from the main script - - ### Args: - * `event` (`FlMidiMsg`): event to encode - * `device_num` (`int`, optional): device number to target. Defaults to - `-1`. - - ### Raises: - * `ValueError`: no target specified on main script (or forwarding from - invalid device) - - ### Returns: - * `bytes`: encoded event data - """ - if device_num == -1: - device_num = getDeviceNum() - if device_num == 1: - # TODO: Use a custom exception type to improve error checking - raise EventEncodeError( - "Either forwarding from an invalid device or target device " - "number is unspecified" - ) - - sysex = getForwardedEventHeader() + bytes([device_num]) - - if isMidiMsgStandard(event): - return sysex + bytes([0]) + bytes([ - event.data2, - event.data1, - event.status, - 0xF7 - ]) - else: - if TYPE_CHECKING: # TODO: Find a way to make this unnecessary - assert isMidiMsgSysex(event) - return sysex + bytes([1]) + bytes(event.sysex) - - -def _getForwardedNameEndIdx(event: FlMidiMsg) -> int: - """ - Returns the index of the null zero of a forwarded event's name - - ### Args: - * `event` (`FlMidiMsg`): event - - ### Returns: - * `int`: index of null zero - """ - assert isMidiMsgSysex(event) - return event.sysex.index(b'\0') - - -def getEventForwardedTo(event: FlMidiMsg) -> str: - """ - Returns the name of the device that this event is targeting - - ### Args: - * `event` (`FlMidiMsg`): event - - ### Returns: - * `str`: device name - """ - assert isMidiMsgSysex(event) - return event.sysex[2:_getForwardedNameEndIdx(event)].decode() - - -def isEventForwardedHere(event: FlMidiMsg) -> bool: - """ - Returns whether an event was forwarded from the Universal Event Forwarder - script from a controller directed to this particular script - - ### Args: - * `event` (`FlMidiMsg`): event to check - - ### Returns: - * `bool`: whether it was forwarded - """ - if not isEventForwarded(event): - return False - - if ( - getEventForwardedTo(event) - != getDeviceId() - ): - return False - return True - - -def getEventDeviceNum(event: FlMidiMsg) -> int: - """ - Returns the device number that a forwarded event is targeting or from - - ### Args: - * `event` (`FlMidiMsg`): event - - ### Returns: - * `int`: device number - """ - assert isMidiMsgSysex(event) - return event.sysex[_getForwardedNameEndIdx(event) + 1] - - -def isEventForwardedHereFrom(event: FlMidiMsg, device_num: int = -1) -> bool: - """ - Returns whether an event was forwarded from a particular instance of the - Universal Event Forwarder script, or is directed to a controller with this - device number - - ### Args: - * `event` (`FlMidiMsg`): event to check - * `device_num` (`int`, optional): device number to match (defaults to the - device number of this script, must be provided on main script) - - ### Returns: - * `bool`: whether it was forwarded - """ - if device_num == -1: - device_num = getDeviceNum() - if device_num == 1: - raise EventInspectError( - "No target device specified from main script" - ) - - if not isEventForwardedHere(event): - return False - - if device_num != getEventDeviceNum(event): - return False - - return True - - -def decodeForwardedEvent(event: FlMidiMsg, type_idx: int = -1) -> FlMidiMsg: - """ - Given a forwarded event, decode it and return the original event - - This function assumes that the event is already proven to be forwarded, - so no additional checks are made. - - ### Args: - * `event` (`FlMidiMsg`): event to decode - * `type_idx` (`int`, optional): index of event type flag, if known. - Defaults to `-1`. - - ### Returns: - * `FlMidiMsg`: decoded data - """ - if not isEventForwarded(event): - raise EventDecodeError(f"Event not forwarded: {eventToString(event)}") - assert isMidiMsgSysex(event) - if type_idx == -1: - type_idx = _getForwardedNameEndIdx(event) + 2 - - if event.sysex[type_idx]: - # Remaining bytes are sysex data - return FlMidiMsg(list(event.sysex[type_idx + 1:])) - else: - # Extract (data2, data1, status) - return FlMidiMsg( - event.sysex[type_idx + 3], - event.sysex[type_idx + 2], - event.sysex[type_idx + 1] - ) - - -def forwardEvent(event: FlMidiMsg, device_num: int = -1): - """ - Encode a forwarded event and send it to all available devices - - ### Args: - * `event` (`FlMidiMsg`): event to encode and forward - * `device_num` (`int`, optional): target device number if on main script - """ - if device_num == -1: - device_num = getDeviceNum() - if device_num == 1: - raise EventEncodeError( - "No target device specified from main script" - ) - output = encodeForwardedEvent(event, device_num) - # Dispatch to all available devices - if device.dispatchReceiverCount() == 0: - raise EventDispatchError( - f"Unable to forward event to/from device {device_num}." - f" Is the controller configured correctly?" - ) - for i in range(device.dispatchReceiverCount()): - device.dispatch(i, 0xF0, output) def eventToRawData(event: FlMidiMsg) -> 'int | bytes': diff --git a/src/common/util/forwarded_events.py b/src/common/util/forwarded_events.py new file mode 100644 index 00000000..7746de0f --- /dev/null +++ b/src/common/util/forwarded_events.py @@ -0,0 +1,144 @@ +""" +common > util > forwarded_events + +Contains code for forwarding events. + +Authors: +* Miguel Guthridge [hdsq@outlook.com.au, HDSQ#2154] + +This code is licensed under the GPL v3 license. Refer to the LICENSE file for +more details. +""" +import device +from typing import TYPE_CHECKING, Optional, Union +from fl_classes import FlMidiMsg, isMidiMsgStandard, isMidiMsgSysex +from common.exceptions import EventDispatchError + + +EVENT_HEADER = bytes([ + 0xF0, # Start sysex + 0x7D # Non-commercial sysex ID +]) +TARGET_INDEX = 2 +ORIGIN_INDEX = 3 +IS_SYSEX_INDEX = 4 + + +def is_event_forwarded(event: FlMidiMsg) -> bool: + """ + Returns whether an event was forwarded from the Universal Event Forwarder + script + + Note that the event isn't necessarily directed towards this device + + ### Args: + * `event` (`FlMidiMsg`): event to check + + ### Returns: + * `bool`: whether it was forwarded + """ + # Check if the event is a forwarded one + # Look for 0xF0 and 0x7D + if not isMidiMsgSysex(event) \ + or not event.sysex.startswith(EVENT_HEADER): + return False + else: + return True + + +def forward_event_to_main(event: FlMidiMsg, origin: int) -> None: + sysex = EVENT_HEADER + bytes([0, origin]) + if isMidiMsgStandard(event): + encoded = sysex + bytes([0]) + bytes([ + event.data2, + event.data1, + event.status, + 0xF7 + ]) + else: + if TYPE_CHECKING: # TODO: Find a way to make this unnecessary + assert isMidiMsgSysex(event) + encoded = sysex + bytes([1]) + bytes(event.sysex) + # Dispatch to all available devices + if device.dispatchReceiverCount() == 0: + raise EventDispatchError( + "Unable to forward event to main device." + " Is the controller configured correctly?" + ) + # Send it to all devices, and make sure they + for i in range(device.dispatchReceiverCount()): + encoded.dispatch(i, 0xF0, encoded) + + +def forward_event_to_external(event: FlMidiMsg, target: int) -> None: + sysex = EVENT_HEADER + bytes([target, 0]) + if isMidiMsgStandard(event): + encoded = sysex + bytes([0]) + bytes([ + event.data2, + event.data1, + event.status, + 0xF7 + ]) + else: + # We need this check, because there is no better way to do it sadly + # https://stackoverflow.com/a/71252167/6335363 + if TYPE_CHECKING: + assert isMidiMsgSysex(event) + encoded = sysex + bytes([1]) + bytes(event.sysex) + # Dispatch to all available devices + if device.dispatchReceiverCount() == 0: + raise EventDispatchError( + f"Unable to forward event to device {target}." + f" Is the controller configured correctly?" + ) + # Send it to all devices, and make sure they + for i in range(device.dispatchReceiverCount()): + encoded.dispatch(i, 0xF0, encoded) + + +def receive_event_from_origin( + event: FlMidiMsg, + device_num: int, +) -> Optional[FlMidiMsg]: + if not is_event_forwarded(event): + return None + assert isMidiMsgSysex(event) + + if event.sysex[TARGET_INDEX] != device_num: + return None + + if event.sysex[IS_SYSEX_INDEX] == 1: + # Remaining bytes are sysex data + return FlMidiMsg(list(event.sysex[IS_SYSEX_INDEX + 1:])) + else: + # Extract (data2, data1, status) + return FlMidiMsg( + event.sysex[IS_SYSEX_INDEX + 3], + event.sysex[IS_SYSEX_INDEX + 2], + event.sysex[IS_SYSEX_INDEX + 1] + ) + + +def receive_event_from_external( + event: FlMidiMsg, +) -> Optional[Union[FlMidiMsg, int]]: + if not is_event_forwarded(event): + return None + assert isMidiMsgSysex(event) + + # If it isn't targeting the main script + if event.sysex[TARGET_INDEX] != 0: + return None + + origin = event.sysex[ORIGIN_INDEX] + + if event.sysex[IS_SYSEX_INDEX] == 1: + # Remaining bytes are sysex data + return FlMidiMsg(list(event.sysex[IS_SYSEX_INDEX + 1:])), origin + else: + # Extract (data2, data1, status) + return FlMidiMsg( + event.sysex[IS_SYSEX_INDEX + 3], + event.sysex[IS_SYSEX_INDEX + 2], + event.sysex[IS_SYSEX_INDEX + 1] + ), origin From 44025f05f0dfbdd5e1f06fce72b570f2789bd3c1 Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Fri, 8 Dec 2023 18:17:18 +1100 Subject: [PATCH 13/15] Finalise redo of forwarded events --- src/common/__init__.py | 7 - src/common/extensions/devices.py | 4 +- src/common/states/device_detect.py | 6 +- src/common/states/error_state.py | 4 +- src/common/states/forward_state.py | 8 +- src/common/states/main_state.py | 8 +- src/common/ucs_context.py | 18 +- src/common/util/events.py | 24 ++- src/common/util/forwarded_events.py | 157 +++++++++++++++--- .../event_patterns/forwarded_pattern.py | 23 ++- .../matchers/indexed_matcher.py | 10 +- .../value_strategies/forwarded_strategy.py | 10 +- src/device_universal.py | 2 +- src/devices/akai/mpk_mini_mk3.py | 2 +- src/devices/device.py | 4 +- src/devices/maudio/hammer88pro/hammer88pro.py | 2 +- .../novation/launchkey/mk2/launchkey.py | 2 +- .../novation/launchkey/mk3/lk_25_37.py | 2 +- .../novation/launchkey/mk3/lk_49_61.py | 2 +- .../novation/launchkey/mk3_mini/mini.py | 2 +- src/devices/novation/sl/mk3/device.py | 2 +- tests/helpers/devices/basic.py | 4 +- 22 files changed, 213 insertions(+), 90 deletions(-) diff --git a/src/common/__init__.py b/src/common/__init__.py index 6084ea76..11bae09b 100644 --- a/src/common/__init__.py +++ b/src/common/__init__.py @@ -30,13 +30,6 @@ from .logger import log, verbosity from .profiler import ProfilerContext, profilerDecoration -from .context_manager import ( - getContext, - resetContext, - unsafeResetContext, - catchContextResetException -) - # Import devices and plugins import devices import integrations diff --git a/src/common/extensions/devices.py b/src/common/extensions/devices.py index 3f7c2456..acdd6df2 100644 --- a/src/common/extensions/devices.py +++ b/src/common/extensions/devices.py @@ -11,7 +11,7 @@ """ from typing import TYPE_CHECKING, Optional from common.types.decorator import SymmetricDecorator -from common.util.events import bytesToString +from common.util.events import bytes_to_string if TYPE_CHECKING: from devices import Device @@ -75,7 +75,7 @@ def inner(device_definition: type['Device']) -> type['Device']: # message, and to make sure people don't abuse decorators to register # multiple devices with one outer `register` call if (found_dev := get_device_matching_id(device_id)) is not None: - device_id_str = bytesToString(device_id) + device_id_str = bytes_to_string(device_id) raise ValueError( f"A device matching device_id {device_id_str} has already " f"been registered.\n\n" diff --git a/src/common/states/device_detect.py b/src/common/states/device_detect.py index 263a6284..c25d7ef1 100644 --- a/src/common/states/device_detect.py +++ b/src/common/states/device_detect.py @@ -20,7 +20,7 @@ from common.exceptions import DeviceRecognizeError from common import log, verbosity from fl_classes import isMidiMsgSysex, FlMidiMsg -from common.util.events import eventToString +from common.util.events import event_to_string from . import IScriptState, ErrorState, DeviceState @@ -157,14 +157,14 @@ def processEvent(self, event: FlMidiMsg) -> None: LOG_CAT, f"Recognized device via sysex: {dev.getId()}", verbosity.INFO, - eventToString(event) + event_to_string(event) ) common.getContext().setState(self._to.create(dev)) except DeviceRecognizeError as e: log( LOG_CAT, f"Failed to recognize device via sysex, using fallback " - f"method {eventToString(event)}", + f"method {event_to_string(event)}", verbosity.INFO, ) try: diff --git a/src/common/states/error_state.py b/src/common/states/error_state.py index af99f634..f48456b8 100644 --- a/src/common/states/error_state.py +++ b/src/common/states/error_state.py @@ -23,7 +23,7 @@ EventDispatchError, InvalidConfigError, ) -from common.util.events import eventToString +from common.util.events import event_to_string from . import IScriptState @@ -152,5 +152,5 @@ def tick(self) -> None: def processEvent(self, event: FlMidiMsg) -> None: log( "bootstrap.device.type_detect", - f"Received event: {eventToString(event)}" + f"Received event: {event_to_string(event)}" ) diff --git a/src/common/states/forward_state.py b/src/common/states/forward_state.py index 2455e924..c104deaf 100644 --- a/src/common/states/forward_state.py +++ b/src/common/states/forward_state.py @@ -21,7 +21,7 @@ from fl_classes import isMidiMsgStandard, isMidiMsgSysex from common.util.events import ( decodeForwardedEvent, - eventToString, + event_to_string, forwardEvent, isEventForwarded, isEventForwardedHereFrom @@ -52,7 +52,7 @@ def outputForwarded(event: FlMidiMsg): ) log( "device.forward.in", - "Output event to device: " + eventToString(event) + "Output event to device: " + event_to_string(event) ) @@ -63,7 +63,7 @@ class ForwardState(DeviceState): """ def __init__(self, device: 'Device') -> None: - if device.getDeviceNumber() == 1: + if device.get_device_number() == 1: raise ValueError( "The main device should be configured to use the main " "'Universal Controller' script, rather than the 'Universal " @@ -93,6 +93,6 @@ def processEvent(self, event: FlMidiMsg) -> None: forwardEvent(event) log( "device.forward.out", - "Dispatched event to main script: " + eventToString(event) + "Dispatched event to main script: " + event_to_string(event) ) event.handled = True diff --git a/src/common/states/main_state.py b/src/common/states/main_state.py index 1dc6243b..99dbb673 100644 --- a/src/common/states/main_state.py +++ b/src/common/states/main_state.py @@ -18,7 +18,7 @@ from common import log, verbosity from fl_classes import FlMidiMsg from common.plug_indexes import PluginIndex, WindowIndex -from common.util.events import eventToString +from common.util.events import event_to_string from .dev_state import DeviceState if TYPE_CHECKING: @@ -32,7 +32,7 @@ class MainState(DeviceState): """ def __init__(self, device: 'Device') -> None: - if device.getDeviceNumber() != 1: + if device.get_device_number() != 1: raise ValueError( "Non-main devices should be configured to use the 'Universal " "Event Forwarder' script, rather than the main 'Universal " @@ -113,7 +113,7 @@ def processEvent(self, event: FlMidiMsg) -> None: event.handled = True log( "device.event.in", - f"Failed to recognize event: {eventToString(event)}", + f"Failed to recognize event: {event_to_string(event)}", verbosity.CRITICAL, "This usually means that the device hasn't been configured " "correctly. Please contact the device's maintainer." @@ -128,7 +128,7 @@ def processEvent(self, event: FlMidiMsg) -> None: "device.event.in", f"Recognized event: {mapping.getControl()}", verbosity.EVENT, - detailed_msg=eventToString(event) + detailed_msg=event_to_string(event) ) # Get active standard plugin diff --git a/src/common/ucs_context.py b/src/common/ucs_context.py index 8ad1338b..161675ed 100644 --- a/src/common/ucs_context.py +++ b/src/common/ucs_context.py @@ -12,7 +12,7 @@ """ from fl_classes import FlMidiMsg from time import time_ns -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from .extensions.integrations import ( get_core_preprocess_integrations, @@ -25,6 +25,7 @@ from .plug_indexes import WindowIndex from .util.api_fixes import catchUnsafeOperation from .util.misc import NoneNoPrintout +from .util.forwarded_events import handle_event_on_external if TYPE_CHECKING: from devices import Device @@ -44,6 +45,8 @@ def __init__(self, device: 'Device') -> None: """Whether the script is currently initialized and running""" self.device = device """The object representing the overall device""" + self.device_num = device.get_device_number() + """The device number, used to forward events""" self.settings = load_configuration() """The configuration of the script""" self.activity = ActivityState() @@ -96,6 +99,9 @@ def initialize(self) -> None: """ Initialize the device """ + # No need to do anything unless this is the main script + if self.device_num != 0: + return self.device.initialize() self.initialized = True @@ -103,6 +109,9 @@ def deinitialize(self) -> None: """ Deinitialize the device """ + # No need to do anything unless this is the main script + if self.device_num != 0: + return self.device.deinitialize() self.initialized = False @@ -118,7 +127,8 @@ def process_event(self, event: FlMidiMsg) -> None: if not self.initialized: return - # TODO: Process forwarded events here + if self.device_num != 0: + return handle_event_on_external(self.device_num, event) # TODO: write this @@ -127,6 +137,10 @@ def tick(self) -> None: """ Called frequently to let devices and integrations update. """ + # No need to do anything unless this is the main script + if self.device_num != 0: + return + # If the script isn't initialized, performing a tick could be dangerous # and get it into an invalid state if not self.initialized: diff --git a/src/common/util/events.py b/src/common/util/events.py index 591cc692..f9d6f08e 100644 --- a/src/common/util/events.py +++ b/src/common/util/events.py @@ -10,9 +10,15 @@ more details. """ from fl_classes import FlMidiMsg, isMidiMsgStandard, isMidiMsgSysex +from .forwarded_events import ( + is_event_forwarded, + get_forwarded_origin_device, + get_forwarded_target_device, + decode_forwarded_event, +) -def eventToRawData(event: FlMidiMsg) -> 'int | bytes': +def event_to_raw_data(event: FlMidiMsg) -> 'int | bytes': """ Convert event to raw data. @@ -29,7 +35,7 @@ def eventToRawData(event: FlMidiMsg) -> 'int | bytes': return event.sysex -def bytesToString(bytes_iter: bytes) -> str: +def bytes_to_string(bytes_iter: bytes) -> str: """ Convert bytes to a fancy formatted string @@ -42,7 +48,7 @@ def bytesToString(bytes_iter: bytes) -> str: return f"[{', '.join(f'0x{b:02X}' for b in bytes_iter)}]" -def eventToString(event: FlMidiMsg) -> str: +def event_to_string(event: FlMidiMsg) -> str: """ Convert event to string @@ -58,9 +64,9 @@ def eventToString(event: FlMidiMsg) -> str: ) else: assert isMidiMsgSysex(event) - if not isEventForwarded(event): - return bytesToString(event.sysex) - dev = getEventForwardedTo(event) - num = getEventDeviceNum(event) - decoded = eventToString(decodeForwardedEvent(event)) - return f"{dev}@{num} => {decoded})" + if not is_event_forwarded(event): + return bytes_to_string(event.sysex) + origin = get_forwarded_origin_device(event) + target = get_forwarded_target_device(event) + decoded = event_to_string(decode_forwarded_event(event)) + return f"{origin}->{target} | {decoded})" diff --git a/src/common/util/forwarded_events.py b/src/common/util/forwarded_events.py index 7746de0f..48eb8ff8 100644 --- a/src/common/util/forwarded_events.py +++ b/src/common/util/forwarded_events.py @@ -10,8 +10,9 @@ more details. """ import device -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Optional from fl_classes import FlMidiMsg, isMidiMsgStandard, isMidiMsgSysex +from . import events from common.exceptions import EventDispatchError @@ -46,7 +47,44 @@ def is_event_forwarded(event: FlMidiMsg) -> bool: return True -def forward_event_to_main(event: FlMidiMsg, origin: int) -> None: +def get_forwarded_origin_device(event: FlMidiMsg) -> int: + """ + Returns the origin device of a forwarded event + + ### Args + * `event` (`FlMidiMsg`): message to parse + + ### Returns + * `int`: origin device number + """ + return event.sysex[ORIGIN_INDEX] + + +def get_forwarded_target_device(event: FlMidiMsg) -> int: + """ + Returns the target device of a forwarded event + + ### Args + * `event` (`FlMidiMsg`): message to parse + + ### Returns + * `int`: target device number + """ + return event.sysex[TARGET_INDEX] + + +def encode_forwarded_event(event: FlMidiMsg, origin: int): + """ + Encode an event to prepare it to be forwarded to secondary ports + + ### Args + * `event` (`FlMidiMsg`): event to encode + + * `origin` (`int`): device number from which the event originates + + ### Returns + * `bytes`: encoded sysex event + """ sysex = EVENT_HEADER + bytes([0, origin]) if isMidiMsgStandard(event): encoded = sysex + bytes([0]) + bytes([ @@ -59,6 +97,19 @@ def forward_event_to_main(event: FlMidiMsg, origin: int) -> None: if TYPE_CHECKING: # TODO: Find a way to make this unnecessary assert isMidiMsgSysex(event) encoded = sysex + bytes([1]) + bytes(event.sysex) + return encoded + + +def forward_event_to_main(event: FlMidiMsg, origin: int) -> None: + """ + Given an event from an origin device, forward it to the main device + + ### Args + * `event` (`FlMidiMsg`): message to encode and forward + + * `origin` (`int`): origin device number + """ + encoded = encode_forwarded_event(event, origin) # Dispatch to all available devices if device.dispatchReceiverCount() == 0: raise EventDispatchError( @@ -71,6 +122,14 @@ def forward_event_to_main(event: FlMidiMsg, origin: int) -> None: def forward_event_to_external(event: FlMidiMsg, target: int) -> None: + """ + Given an event from an origin device, forward it to the main device + + ### Args + * `event` (`FlMidiMsg`): message to encode and forward + + * `origin` (`int`): origin device number + """ sysex = EVENT_HEADER + bytes([target, 0]) if isMidiMsgStandard(event): encoded = sysex + bytes([0]) + bytes([ @@ -96,17 +155,16 @@ def forward_event_to_external(event: FlMidiMsg, target: int) -> None: encoded.dispatch(i, 0xF0, encoded) -def receive_event_from_origin( - event: FlMidiMsg, - device_num: int, -) -> Optional[FlMidiMsg]: - if not is_event_forwarded(event): - return None - assert isMidiMsgSysex(event) +def decode_forwarded_event(event: FlMidiMsg) -> FlMidiMsg: + """ + Given a forwarded event, decode it and return the result - if event.sysex[TARGET_INDEX] != device_num: - return None + ### Args + * `event` (`FlMidiMsg`): event to decode + ### Returns + * `FlMidiMsg`: decoded event + """ if event.sysex[IS_SYSEX_INDEX] == 1: # Remaining bytes are sysex data return FlMidiMsg(list(event.sysex[IS_SYSEX_INDEX + 1:])) @@ -119,26 +177,79 @@ def receive_event_from_origin( ) +def receive_event_from_main( + event: FlMidiMsg, + device_num: int, +) -> Optional[FlMidiMsg]: + """ + Attempt to receive an event forwarded from the main script + + ### Args + * `event` (`FlMidiMsg`): event to receive + + * `device_num` (`int`): device number that is receiving the event + + ### Returns + * `Optional[FlMidiMsg]`: decoded message, if it was forwarded from the main + script, otherwise, `None`. + """ + if not is_event_forwarded(event): + return None + assert isMidiMsgSysex(event) + + if get_forwarded_target_device(event) != device_num: + return None + return decode_forwarded_event(event) + + def receive_event_from_external( event: FlMidiMsg, -) -> Optional[Union[FlMidiMsg, int]]: +) -> Optional[tuple[FlMidiMsg, int]]: + """ + Attempt to receive an event from an external controller, and return the + decoded event and the origin device number it was sent from. + + ### Args + * `event` (`FlMidiMsg`): event to attempt to receive + + ### Returns + * `Optional[Union[FlMidiMsg, int]]`: decoded event and origin device number + if event was forwarded, else `None`. + """ if not is_event_forwarded(event): return None assert isMidiMsgSysex(event) # If it isn't targeting the main script - if event.sysex[TARGET_INDEX] != 0: + if get_forwarded_target_device(event) != 0: return None - origin = event.sysex[ORIGIN_INDEX] + origin = get_forwarded_origin_device(event) + return decode_forwarded_event(event), origin - if event.sysex[IS_SYSEX_INDEX] == 1: - # Remaining bytes are sysex data - return FlMidiMsg(list(event.sysex[IS_SYSEX_INDEX + 1:])), origin + +def handle_event_on_external( + device_num: int, + event: FlMidiMsg, +) -> None: + """ + Handle incoming MIDI messages on secondary port + + For events that are forwarded here, output them to this port, and for + events that originated from this port, forward them to the main script. + + ### Args + * `device_num` (`int`): device number to handle events for + + * `event` (`FlMidiMsg`): event to handle + """ + if is_event_forwarded(event): + decoded = receive_event_from_main(event, device_num) + if decoded is not None: + if isMidiMsgSysex(decoded): + device.midiOutSysex(decoded.sysex) + else: + assert isMidiMsgStandard(decoded) + device.midiOutMsg(events.event_to_raw_data(decoded)) else: - # Extract (data2, data1, status) - return FlMidiMsg( - event.sysex[IS_SYSEX_INDEX + 3], - event.sysex[IS_SYSEX_INDEX + 2], - event.sysex[IS_SYSEX_INDEX + 1] - ), origin + forward_event_to_main(event, device_num) diff --git a/src/control_surfaces/event_patterns/forwarded_pattern.py b/src/control_surfaces/event_patterns/forwarded_pattern.py index c3b4ed83..3ce0f251 100644 --- a/src/control_surfaces/event_patterns/forwarded_pattern.py +++ b/src/control_surfaces/event_patterns/forwarded_pattern.py @@ -10,10 +10,9 @@ more details. """ -from common.util.events import ( - decodeForwardedEvent, - encodeForwardedEvent, - isEventForwardedHereFrom, +from common.util.forwarded_events import ( + receive_event_from_external, + encode_forwarded_event, ) from . import IEventPattern, UnionPattern @@ -47,17 +46,17 @@ def __init__(self, device_num: int, pattern: IEventPattern) -> None: def matchEvent(self, event: FlMidiMsg) -> bool: # Check if the event was forwarded here - if not isEventForwardedHereFrom(event, self._device_num): - return False - - # Extract the event and determine if it matches with the - # underlying pattern - # print(eventToString(eventFromForwarded(event, null+2))) - return self._pattern.matchEvent(decodeForwardedEvent(event)) + if (info := receive_event_from_external(event)) is not None: + decoded, device_num = info + return ( + device_num == self._device_num + and self._pattern.matchEvent(decoded) + ) + return False def fulfil(self) -> FlMidiMsg: num = self._device_num - return FlMidiMsg(encodeForwardedEvent(self._pattern.fulfil(), num)) + return FlMidiMsg(encode_forwarded_event(self._pattern.fulfil(), num)) class ForwardedUnionPattern(IEventPattern): diff --git a/src/control_surfaces/matchers/indexed_matcher.py b/src/control_surfaces/matchers/indexed_matcher.py index 47bbb85c..31d4fc65 100644 --- a/src/control_surfaces/matchers/indexed_matcher.py +++ b/src/control_surfaces/matchers/indexed_matcher.py @@ -17,7 +17,7 @@ ForwardedPattern ) from fl_classes import FlMidiMsg, isMidiMsgStandard -from common.util.events import decodeForwardedEvent +from common.util.events import decode_forwarded_event from control_surfaces import ControlEvent, ControlSurface from . import IControlMatcher @@ -36,7 +36,7 @@ def __init__( status: int, data1_start: int, controls: Sequence[ControlSurface], - device: int = 1, + device: int = 0, ) -> None: """ Create an indexed matcher. @@ -49,7 +49,7 @@ def __init__( * `controls` (`list[ControlSurface]`): list of controls to bind * `device` (`int`, optional): device number, to allow for forwarded - events. Defaults to `1`. + events. Defaults to `0` for main device. """ self.__pattern: IEventPattern = BasicPattern( status, @@ -60,7 +60,7 @@ def __init__( self.__start = data1_start self.__controls = controls - if device != 1: + if device != 0: self.__forwarded = True self.__pattern = ForwardedPattern(device, self.__pattern) else: @@ -83,7 +83,7 @@ def matchEvent(self, event: FlMidiMsg) -> Optional[ControlEvent]: if not self.__pattern.matchEvent(event): return None if self.__forwarded: - decoded = decodeForwardedEvent(event) + decoded = decode_forwarded_event(event) else: decoded = event assert isMidiMsgStandard(decoded) diff --git a/src/control_surfaces/value_strategies/forwarded_strategy.py b/src/control_surfaces/value_strategies/forwarded_strategy.py index 7b9b6a34..890655be 100644 --- a/src/control_surfaces/value_strategies/forwarded_strategy.py +++ b/src/control_surfaces/value_strategies/forwarded_strategy.py @@ -12,7 +12,7 @@ """ from fl_classes import FlMidiMsg -from common.util.events import decodeForwardedEvent, isEventForwarded +from common.util.events import decode_forwarded_event, is_event_forwarded from . import IValueStrategy @@ -28,12 +28,12 @@ def getValueFromEvent(self, event: FlMidiMsg, value: float) -> float: # The value is already matching, so we can cheat somewhat with getting # the data out return self._strat.getValueFromEvent( - decodeForwardedEvent(event), + decode_forwarded_event(event), value, ) def getChannelFromEvent(self, event: FlMidiMsg): - return self._strat.getChannelFromEvent(decodeForwardedEvent(event)) + return self._strat.getChannelFromEvent(decode_forwarded_event(event)) class ForwardedUnionStrategy(IValueStrategy): @@ -46,13 +46,13 @@ def __init__(self, strat: IValueStrategy) -> None: self._strat_forward = ForwardedStrategy(strat) def getValueFromEvent(self, event: FlMidiMsg, value: float) -> float: - if isEventForwarded(event): + if is_event_forwarded(event): return self._strat_forward.getValueFromEvent(event, value) else: return self._strat.getValueFromEvent(event, value) def getChannelFromEvent(self, event: FlMidiMsg): - if isEventForwarded(event): + if is_event_forwarded(event): return self._strat_forward.getChannelFromEvent(event) else: return self._strat.getChannelFromEvent(event) diff --git a/src/device_universal.py b/src/device_universal.py index 0d5421d6..77ba2ea1 100644 --- a/src/device_universal.py +++ b/src/device_universal.py @@ -38,7 +38,7 @@ # Import verbosity constants from common.logger.verbosity import * # Import some helper functions -from common.util.events import eventToString +from common.util.events import event_to_string # Import first state from common.states import WaitingForDevice, MainState diff --git a/src/devices/akai/mpk_mini_mk3.py b/src/devices/akai/mpk_mini_mk3.py index 18cc7df6..a3c1cfd9 100644 --- a/src/devices/akai/mpk_mini_mk3.py +++ b/src/devices/akai/mpk_mini_mk3.py @@ -100,7 +100,7 @@ def getId(self) -> str: def getDrumPadSize(cls) -> tuple[int, int]: return 4, 4 - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: return 1 @classmethod diff --git a/src/devices/device.py b/src/devices/device.py index 48fa2cf8..de0cb7d2 100644 --- a/src/devices/device.py +++ b/src/devices/device.py @@ -128,7 +128,7 @@ def getId(self) -> str: """ raise AbstractMethodError(self) - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: """ Returns the number of a device @@ -139,7 +139,7 @@ def getDeviceNumber(self) -> int: ### Returns: * `int`: device number - * `1`: Main device + * `0`: Main device * other values: other device numbers. """ return 1 diff --git a/src/devices/maudio/hammer88pro/hammer88pro.py b/src/devices/maudio/hammer88pro/hammer88pro.py index 6fab352e..81994fba 100644 --- a/src/devices/maudio/hammer88pro/hammer88pro.py +++ b/src/devices/maudio/hammer88pro/hammer88pro.py @@ -236,7 +236,7 @@ def getUniversalEnquiryResponsePattern(cls): ] ) - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: name = device.getName() try: diff --git a/src/devices/novation/launchkey/mk2/launchkey.py b/src/devices/novation/launchkey/mk2/launchkey.py index ad005951..0994d303 100644 --- a/src/devices/novation/launchkey/mk2/launchkey.py +++ b/src/devices/novation/launchkey/mk2/launchkey.py @@ -91,7 +91,7 @@ def deinitialize(self) -> None: def getDrumPadSize(cls) -> tuple[int, int]: return 2, 8 - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: name = device.getName() if "MIDIIN2" in name: return 2 diff --git a/src/devices/novation/launchkey/mk3/lk_25_37.py b/src/devices/novation/launchkey/mk3/lk_25_37.py index cdff5d7b..3bdc7461 100644 --- a/src/devices/novation/launchkey/mk3/lk_25_37.py +++ b/src/devices/novation/launchkey/mk3/lk_25_37.py @@ -106,7 +106,7 @@ def deinitialize(self) -> None: def getDrumPadSize(cls) -> tuple[int, int]: return 2, 8 - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: if ( 'MIDIIN2' in device.getName() or 'DAW' in device.getName() diff --git a/src/devices/novation/launchkey/mk3/lk_49_61.py b/src/devices/novation/launchkey/mk3/lk_49_61.py index 176b14a2..518749ee 100644 --- a/src/devices/novation/launchkey/mk3/lk_49_61.py +++ b/src/devices/novation/launchkey/mk3/lk_49_61.py @@ -111,7 +111,7 @@ def deinitialize(self) -> None: def getDrumPadSize(cls) -> tuple[int, int]: return 2, 8 - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: if ( 'MIDIIN2' in device.getName() or 'DAW' in device.getName() diff --git a/src/devices/novation/launchkey/mk3_mini/mini.py b/src/devices/novation/launchkey/mk3_mini/mini.py index a11e4522..0ea15e29 100644 --- a/src/devices/novation/launchkey/mk3_mini/mini.py +++ b/src/devices/novation/launchkey/mk3_mini/mini.py @@ -69,7 +69,7 @@ def deinitialize(self) -> None: def getDrumPadSize(cls) -> tuple[int, int]: return 2, 8 - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: if ( 'MIDIIN2' in device.getName() or 'DAW' in device.getName() diff --git a/src/devices/novation/sl/mk3/device.py b/src/devices/novation/sl/mk3/device.py index 35865a53..caa78fbc 100644 --- a/src/devices/novation/sl/mk3/device.py +++ b/src/devices/novation/sl/mk3/device.py @@ -114,7 +114,7 @@ def __init__(self) -> None: def getDrumPadSize(cls) -> tuple[int, int]: return 2, 8 - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: return 2 if '2' in device.getName() else 1 @classmethod diff --git a/tests/helpers/devices/basic.py b/tests/helpers/devices/basic.py index e5c1a148..bf57926e 100644 --- a/tests/helpers/devices/basic.py +++ b/tests/helpers/devices/basic.py @@ -51,7 +51,7 @@ def getSupportedIds(cls) -> tuple[str, ...]: def getUniversalEnquiryResponsePattern() -> Optional[IEventPattern]: return None - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: return 1 @staticmethod @@ -120,7 +120,7 @@ def create( ) -> 'Device': return cls() - def getDeviceNumber(self) -> int: + def get_device_number(self) -> int: return self._num From 0c7be1a0c8ea51bc4668d9c1b18101ef181ee825 Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Fri, 8 Dec 2023 18:17:58 +1100 Subject: [PATCH 14/15] Add note to docs for required update --- docs/contributing/devices/event_forward.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributing/devices/event_forward.md b/docs/contributing/devices/event_forward.md index 2f6c3957..9201b5da 100644 --- a/docs/contributing/devices/event_forward.md +++ b/docs/contributing/devices/event_forward.md @@ -1,6 +1,8 @@ # Event Forwarding +FIXME: Update this for the v2.0 release + For devices that use multiple ports, the Universal Controller Script uses a many-to-one one-to-many model for processing events. The main script should be assigned to the MIDI port for your controller, and the forwarder script should From c172cb87450ff4887fa9bd8e7e353d03ee11524a Mon Sep 17 00:00:00 2001 From: Miguel Guthridge Date: Sun, 14 Jan 2024 13:22:38 +1100 Subject: [PATCH 15/15] Minor type safety improvements --- src/common/activity_state.py | 4 +--- src/common/util/api_fixes.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/common/activity_state.py b/src/common/activity_state.py index 3fe73d69..d00b3fec 100644 --- a/src/common/activity_state.py +++ b/src/common/activity_state.py @@ -76,12 +76,10 @@ def _forcePlugUpdate(self) -> None: Used so that split windows and plugins behaves correctly. """ plugin = getFocusedPluginIndex(force=True) - if plugin is None: - raise TypeError("Wait this shouldn't be possible") if self._plugin != plugin: self._plugin = plugin try: - self._plugin_name = plugins.getPluginName(*plugin) + self._plugin_name = plugin.getName() except TypeError: self._plugin_name = "" if isinstance(plugin, GeneratorIndex): diff --git a/src/common/util/api_fixes.py b/src/common/util/api_fixes.py index d3831021..727126fd 100644 --- a/src/common/util/api_fixes.py +++ b/src/common/util/api_fixes.py @@ -18,13 +18,23 @@ import playlist from common.profiler import profilerDecoration -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, overload, Literal if TYPE_CHECKING: from common.plug_indexes import WindowIndex, PluginIndex +@overload +def getFocusedPluginIndex(force: Literal[True]) -> 'PluginIndex': + ... + + +@overload +def getFocusedPluginIndex(force: Literal[False]) -> Optional['PluginIndex']: + ... + + @profilerDecoration("getFocusedPluginIndex") def getFocusedPluginIndex(force: bool = False) -> Optional['PluginIndex']: """