Compare commits

...

6 Commits

Author SHA1 Message Date
JessyJP 3ac25d276b Merge branch 'MidiPlugin' into 'master'
(Complete) MIDI pluging, complete with transmitter, reciever, dynamic assignement and device profiles

See merge request openlp/openlp!680
2024-04-27 05:43:08 +00:00
Raoul Snyman 6bbfe00ec0 Merge branch 'translations-21042024' into 'master'
Translations 21042024

See merge request openlp/openlp!748
2024-04-27 05:42:58 +00:00
Tim Bentley cee0a9d573 Translations 21042024 2024-04-27 05:42:58 +00:00
JessyJP f05d64be07 Revert "Update define_midi_event_action_mapping.py"
This reverts commit 1dd2e265ba.
2024-04-10 13:39:54 +01:00
JessyJP f6f6c0291f Update define_midi_event_action_mapping.py 2024-04-10 13:39:54 +01:00
JessyJP 6fca42a753 Create MIDI control plugin 2024-04-10 13:39:54 +01:00
59 changed files with 17236 additions and 12090 deletions

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`midi` module provides the MIDI plugin.
The MIDI plugin offers the capability for duplex MIDI control and interaction within OpenLP.
"""
# NOTE: Technically it would be good to have a separate class to act as a profile template.
# An instance of this template could be unpacked in the ORM, or alternatively, the template could be created
# (unpacked from the ORM). This will provide validation methods and consistency.
# However, at this point it is not necessary. The midi-action-event mappings are always utilized dynamically.
# The full profile is only fully utilized by the configuration menu tab. When it exports it's state
# it packs it into a dictionary(not as important) and the midi-action-event into a SimpleNamespace to maintain
# consistency when the set property is called. Therefore, this is sufficient.
# All other methods only read the full profile and get it in the form of a dictionary,
# thus consistency is maintained. If there are more ways to import a profile then a template calls should be used.
# In all cases the database manager will ensure the type checking of the input profile state or individual fields.

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The MIDI plugin in OpenLP provides integration with MIDI devices and allows users to configure MIDI events to interact
with OpenLP functions.
Forms in OpenLP's MIDI plugin follow a two-class approach similar to other plugins:
1. The **Dialog** class, named `Ui_<name>Dialog`, which holds the graphical elements. This is a version adapted from the
output of the `pyuic5` tool, typically using single quotes and OpenLP's `translate()` function for translatable strings.
2. The **Form** class, named `<name>Form`, which contains the functionality. It is instantiated and used in the main
application. This class inherits from both a Qt widget (often `QtWidgets.QDialog`) and the corresponding Ui class,
enabling access to GUI elements via `self.object`.
For example::
class MidiSettingsForm(QtWidgets.QDialog, Ui_MidiSettingsDialog):
def __init__(self, parent=None):
super(MidiSettingsForm, self).__init__(parent)
self.setup_ui(self)
This dual inheritance pattern allows for a separation of GUI and logic, and also facilitates future changes to the GUI
from the .ui files, if required.
"""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################

View File

@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import mido
import rtmidi
import pygame
import pygame.midi
import re
import logging
from openlp.plugins.midi.lib.types_definitions.constants import midi_ch_any
log = logging.getLogger(__name__)
class Mido_DeviceHandler:
@staticmethod
def _get_input_midi_devices() -> list:
"""
Get a list of input MIDI device names.
"""
try:
devices = mido.get_input_names()
return devices
except Exception as e:
log.error(f"Error getting input MIDI devices: {e}")
return []
@staticmethod
def _get_output_midi_devices() -> list:
"""
Get a list of output MIDI device names.
Returns:
list: A list of detected output MIDI device names.
Returns an empty list if there's an error or no devices are detected.
"""
try:
devices = mido.get_output_names()
return devices
except Exception as e:
log.error(f"Error getting output MIDI devices: {e}")
return []
class RtMidi_DeviceHandler:
midi_in = rtmidi.MidiIn()
midi_out = rtmidi.MidiOut()
@staticmethod
def _get_input_midi_devices() -> list:
"""
Get a list of input MIDI device names using rtmidi.
"""
try:
devices = RtMidi_DeviceHandler.midi_in.get_ports()
return devices
except Exception as e:
log.error(f"Error getting RTMidi input devices: {e}")
return []
@staticmethod
def _get_output_midi_devices() -> list:
"""
Get a list of output MIDI device names using rtmidi.
"""
try:
devices = RtMidi_DeviceHandler.midi_out.get_ports()
return devices
except Exception as e:
log.error(f"Error getting RTMidi output devices: {e}")
return []
class PygameMidi_DeviceHandler:
@staticmethod
def init_pygame_midi():
pygame.init()
pygame.midi.init()
@staticmethod
def _get_input_midi_devices() -> list:
"""
Get a list of input MIDI device names using pygame.
"""
try:
PygameMidi_DeviceHandler.init_pygame_midi()
# devices = [pygame.midi.get_device_info(i) for i in range(pygame.midi.get_count())
devices = [pygame.midi.get_device_info(i)[1].decode() for i in range(pygame.midi.get_count())
if pygame.midi.get_device_info(i)[1] and pygame.midi.get_device_info(i)[2] == 1]
return devices
except Exception as e:
log.error(f"Error getting pygame input MIDI devices: {e}")
return []
@staticmethod
def _get_output_midi_devices() -> list:
"""
Get a list of output MIDI device names using pygame.
"""
try:
PygameMidi_DeviceHandler.init_pygame_midi()
devices = [pygame.midi.get_device_info(i)[1].decode() for i in range(pygame.midi.get_count())
if pygame.midi.get_device_info(i)[1] and pygame.midi.get_device_info(i)[2] == 0]
return devices
except Exception as e:
log.error(f"Error getting pygame output MIDI devices: {e}")
return []
class MidiDeviceHandler(Mido_DeviceHandler):
_should_sanitize_names = True # Private static switch to control name sanitization
@staticmethod
def should_sanitize_names():
"""
Get the current status of the sanitize_names property.
"""
return MidiDeviceHandler._should_sanitize_names
@staticmethod
def _get_superclass_name():
return MidiDeviceHandler.__bases__[0].__name__
@staticmethod
def _sanitize_device_name(name):
"""
Sanitize the MIDI device name by removing trailing index numbers.
"""
# This regular expression matches a space followed by any number of digits at the end of the string
return re.sub(r'\s+\d+$', '', name)
@staticmethod
def get_input_midi_devices() -> list:
"""
Get a list of input MIDI device names and optionally sanitize them.
"""
# Choose which library to use (mido, rtmidi, or pygame) for getting device names
# This is a placeholder logic; adjust as needed
devices = MidiDeviceHandler._get_input_midi_devices()
log.debug(f"Detected input MIDI devices via [{MidiDeviceHandler._get_superclass_name()}]: {devices}")
print(f"Detected input MIDI devices via [{MidiDeviceHandler._get_superclass_name()}]: {devices}")
if MidiDeviceHandler._should_sanitize_names:
return [MidiDeviceHandler._sanitize_device_name(name) for name in devices]
else:
return devices
@staticmethod
def get_output_midi_devices() -> list:
"""
Get a list of output MIDI device names and optionally sanitize them.
"""
# Choose which library to use (mido, rtmidi, or pygame) for getting device names
# This is a placeholder logic; adjust as needed
devices = MidiDeviceHandler._get_output_midi_devices()
log.debug(f"Detected output MIDI devices via [{MidiDeviceHandler._get_superclass_name()}]: {devices}")
print(f"Detected output MIDI devices via [{MidiDeviceHandler._get_superclass_name()}]: {devices}")
if MidiDeviceHandler._should_sanitize_names:
return [MidiDeviceHandler._sanitize_device_name(name) for name in devices]
else:
return devices
@staticmethod
def match_exact_input_device_name(sanitized_name):
"""
Get the exact name of an input MIDI device from its sanitized name.
"""
# Placeholder logic for matching sanitized name to exact name
devices = MidiDeviceHandler._get_input_midi_devices()
for device in devices:
if MidiDeviceHandler._sanitize_device_name(device) == sanitized_name:
return device
return sanitized_name
@staticmethod
def match_exact_output_device_name(sanitized_name):
"""
Get the exact name of an output MIDI device from its sanitized name.
"""
# Placeholder logic for matching sanitized name to exact name
devices = MidiDeviceHandler._get_output_midi_devices()
for device in devices:
if MidiDeviceHandler._sanitize_device_name(device) == sanitized_name:
return device
return sanitized_name
@staticmethod
def get_midi_channels_list():
"""
For this example, we'll return a static list of 16 MIDI channels.
In a more complex setup, you might want to detect available channels or have other logic.
"""
return [midi_ch_any] + [f"{i}" for i in range(1, 17)]
@staticmethod
def get_channel_index(channel):
"""
Get the index of a given channel in the MIDI channels list.
The channel can be an int or a string.
"""
midi_channel_option_list = MidiDeviceHandler.get_midi_channels_list()
# Convert channel to string if it's not, to match the format in the list
channel_str = str(channel)
# Return the index if the channel is in the list, otherwise return None
return midi_channel_option_list.index(channel_str) if channel_str in midi_channel_option_list else None

View File

@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`midi_receiver_service` module contains the MIDI receiver service. This service listens for MIDI events in real
time and performs necessary actions.
"""
import logging
from dataclasses import dataclass
from typing import Optional, Union
from openlp.core.common.mixins import LogMixin, RegistryProperties
from openlp.core.common.registry import Registry, RegistryBase
from openlp.core.threading import run_thread, make_remove_thread, is_thread_finished
from openlp.plugins.midi.lib.handlers_managers.profile_db_manager import get_midi_configuration
from openlp.plugins.midi.lib.midi.transmitter import MidiEventTransmitter
from openlp.plugins.midi.lib.midi.listener import MidiEventListener
from openlp.plugins.midi.lib.handlers_managers.state_event_manager import EventStateManager, dict_diff
from PyQt5 import QtCore
log = logging.getLogger(__name__)
# TODO: This could be moved to the midi plugin file
state_mgr = EventStateManager()
@dataclass
class MidiServiceMessage:
plugin: Optional[str]
key: str
value: Union[int, str, dict, list]
class MidiControlService(RegistryBase, RegistryProperties, QtCore.QObject, LogMixin):
"""
This is the MIDI control service that handles, the states of the MIDI control components and workers.
Wrapper around the MIDI listener instance.
"""
_send_message_signal = QtCore.pyqtSignal(MidiServiceMessage)
def __init__(self, parent=None):
"""
Initialise the MIDI receiver service.
"""
super(MidiControlService, self).__init__()
# TODO: super(MidiControlService, self).__init__(parent)
# TODO: do i need to specify the parent here
self.listener_worker = None
self.transmitter_worker = None
self._send_message_signal.connect(self.__handle_message_signal)
Registry().register('midi_service_handle', self)
self.post_bootstrap = False
def bootstrap_post_set_up(self):
if Registry().get_flag('midi_service_active'):
self.start()
def start(self):
"""
Start the MIDI listener.
"""
if self.listener_worker is None:
self.start_listener()
if self.transmitter_worker is None:
self.transmitter_worker = MidiEventTransmitter()
if not self.transmitter_worker.is_disabled():
state_mgr.poller_changed.connect(self.handle_poller_signal)
# Only hooking poller signals after all UI is available
if not self.post_bootstrap:
# NOTE: it's simply a matter of not know if the first time, it will be initialized post UI ready
Registry().register_function('bootstrap_completion', self.try_poller_hook_signals)
self.post_bootstrap = True
else:
self.try_poller_hook_signals()
self.transmitter_worker.reset_device_midi_state()
self.initialize_device_state_to_openlp()
state_mgr.set_variable_control_offset(get_midi_configuration().control_offset) # TODO: a bit of lazy setup
state_mgr.set_play_action_type(get_midi_configuration().play_button_is_toggle) # TODO: a bit of lazy setup
def start_listener(self):
if self.listener_worker is None:
self.listener_worker = MidiEventListener(on_receive_callback_ref=state_mgr.doEventCallbackSignal.emit)
self.listener_worker.transmit_callback = self.initialize_device_state_to_openlp
# After initialization figure out the type
if (self.listener_worker.__str__() == 'MidiEventListener_worker_v1' or
self.listener_worker.__str__() == 'MidiEventListener_worker_NB' or
self.listener_worker.__str__() == 'MidiEventListener_INSEPTION'): # TODO TRIPPY
make_remove_thread(self.listener_worker.__str__()) # TODO just in case
run_thread(self.listener_worker, self.listener_worker.__str__())
else:
self.listener_worker.start()
def stop_listener(self):
if self.listener_worker:
# TODO: this is for testing A/B ... /C/D/E comparison
self.listener_worker.stop()
if self.listener_worker.__str__() == "MidiEventListener_worker_v1": # NOTE: this will be
# MidiEventListener_worker <= the blocking listener
make_remove_thread(self.listener_worker.__str__()) # TODO: it never coms here because it hasn't
# TODO ugly -- managed to close the thread
import time
time.sleep(1) # TODO: we block to test if this will allow the thread to close in the mean time
if is_thread_finished(self.listener_worker.__str__()):
make_remove_thread(self.listener_worker.__str__()) # TODO: it never coms here because it hasn't
# TODO ugly -- managed to close the thread
# else:
# print(f"The [{self.listener_worker.__str__()}] thread is not finished!")
# thread_info = Registry().get('application').worker_threads.get
# (self.listener_worker.__str__())['thread']
# del thread_info
# make_remove_thread(self.listener_worker.__str__())
# # raise("The thread is not stopping. It's likely blocked!")
@QtCore.pyqtSlot()
def handle_poller_signal(self):
if self.transmitter_worker is not None:
self.transmitter_worker.handle_state_change(state_mgr.get_state_diff())
def initialize_device_state_to_openlp(self):
state = state_mgr.get_openlp_state()
dummy = {event: None for event, _ in state.items()}
diff = dict_diff(dummy, state)
self.transmitter_worker.handle_state_change(diff)
def send_message(self, message: MidiServiceMessage):
# Using a signal to run emission on this thread
self._send_message_signal.emit(message)
def __handle_message_signal(self, message: MidiServiceMessage):
if self.listener_worker is not None:
self.listener_worker.add_message_to_queues(message)
def try_poller_hook_signals(self):
try:
state_mgr.post_initialization_get_state()
state_mgr.hook_signals()
except Exception:
log.error('ERROR: Failed to hook poller signals!')
def close(self):
"""
Closes the Midi service and detach associated signals
"""
if not Registry().get_flag('midi_service_active'):
return
try:
if not self.transmitter_worker.is_disabled():
state_mgr.poller_changed.disconnect(self.handle_poller_signal)
state_mgr.unhook_signals()
# Close the emitter
self.transmitter_worker.close()
finally:
self.transmitter_worker = None
try:
# Stop the listener
self.stop_listener()
finally:
self.listener_worker = None
def __del__(self):
self.close()

View File

@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`db` module provides the database and schema that is the backend for the Midi plugin.
"""
import copy
from typing import List, Any
from types import SimpleNamespace
from openlp.core.db.manager import DBManager
from openlp.plugins.midi.lib.types_definitions.define_midi_event_action_mapping import get_default_action_midi_mappings
from openlp.plugins.midi.lib.types_definitions.config_profile_orm import (MidiConfigurationProfileDbORM,
ProfileNotFoundException,
init_schema_midi_plugin_dtb)
def get_default_action_midi_mappings_as_dict() -> dict:
# Convert the list to a dictionary
mappings_dict = {mapping.mapping_key: mapping for mapping in get_default_action_midi_mappings()}
return mappings_dict
class MidiProfileManager:
"""
MIDI Database Manager to encapsulate database interactions.
"""
def __init__(self):
"""
Initialize the MidiDatabaseManager.
"""
# TODO: is this needed => upgrade_mod=upgrade
self.sql_lite_db_manager = DBManager('midi', init_schema=init_schema_midi_plugin_dtb)
self.session = self.sql_lite_db_manager.session
self.event_action_mappings = get_default_action_midi_mappings_as_dict()
# Perform a startup check that the database and the default profile exist
self._check_default_profile_at_startup()
def create_profile(self, profile_name: str) -> None:
"""
Create a new MIDI profile.
:param profile_name: The name of the profile to be created.
"""
MidiConfigurationProfileDbORM.create_profile(self.session, profile_name)
def delete_profile(self, profile_name: str) -> None:
"""
Delete a MIDI profile.
:param profile_name: The name of the profile to be deleted.
"""
MidiConfigurationProfileDbORM.delete_profile(self.session, profile_name)
def rename_profile(self, old_name: str, new_name: str) -> None:
"""
Rename a MIDI profile.
:param old_name: Current name of the profile.
:param new_name: New name for the profile.
"""
MidiConfigurationProfileDbORM.rename_profile(self.session, old_name, new_name)
def get_profile_state(self, profile_name: str) -> dict:
"""
Retrieve all properties for a given MIDI profile.
:param profile_name: The name of the profile.
:return: Dictionary containing all properties of the profile.
"""
profile = self.session.query(MidiConfigurationProfileDbORM).filter_by(profile=profile_name).first()
if not profile:
raise ProfileNotFoundException(f"Profile '{profile_name}' not found.")
# Construct the profile state dictionary. It is more efficient than calling get property individually in a loop
profile_state = {}
for column in MidiConfigurationProfileDbORM.__table__.columns:
profile_state[column.name] = getattr(profile, column.name)
# Parse the events to a structure
if "event" in column.name:
# TODO: consider if we want to have a copy or pass the reference as is
orm_str = profile_state[column.name]
profile_state[column.name] = self.event_action_mappings[column.name].copy()
profile_state[column.name].update_from_orm_string(orm_str)
return profile_state
def get_all_profiles(self) -> List[str]:
"""
Retrieve all the available MIDI profiles.
:return: List of profile names.
"""
return MidiConfigurationProfileDbORM.get_all_profiles(self.session)
def get_selected_profile_name(self) -> str:
"""
Get the name of the currently selected profile.
:return: Name of the selected profile.
"""
for profile in self.get_all_profiles():
if self.get_property(profile, 'is_selected_profile'):
return profile
return None
def set_profile_as_currently_selected(self, currently_selected_profile: str) -> None:
"""
Set a profile as the currently selected one.
:param currently_selected_profile: The name of the profile to be set as selected.
"""
for profile in self.get_all_profiles():
self.set_property(profile, 'is_selected_profile', profile == currently_selected_profile)
def set_property(self, profile_name: str, property_name: str, value: Any) -> None:
"""
Set a property for a given MIDI profile.
:param profile_name: The name of the profile.
:param property_name: The property name.
:param value: The value to set.
"""
profile = self.session.query(MidiConfigurationProfileDbORM).filter_by(profile=profile_name).first()
if not profile:
raise ProfileNotFoundException(f"Profile '{profile_name}' not found.")
if "event" in property_name and not isinstance(value, str):
mapping_cpy = self.event_action_mappings[property_name]
mapping_cpy.update_from_ui_fields(str(value.midi_type), int(value.midi_data))
value = mapping_cpy.export_to_orm_string()
profile.set_property(property_name, value)
self.session.commit()
def get_property(self, profile_name: str, property_name: str) -> Any:
"""
Retrieve a property for a given MIDI profile.
:param profile_name: The name of the profile.
:param property_name: The property name.
:return: The value of the property.
"""
profile = self.session.query(MidiConfigurationProfileDbORM).filter_by(profile=profile_name).first()
if not profile:
raise ProfileNotFoundException(f"Profile '{profile_name}' not found.")
prop_value = profile.get_property(property_name)
if "event" in property_name:
# TODO: consider if we want to have a copy or pass the reference as is
orm_str = prop_value
prop_value = self.event_action_mappings[property_name].copy()
prop_value.update_from_orm_string(orm_str)
return prop_value
def _check_default_profile_at_startup(self) -> None:
"""
Check and create the default profile at startup if necessary.
"""
profile_name = "default"
profile = self.session.query(MidiConfigurationProfileDbORM).filter_by(profile=profile_name).first()
if not profile:
# If no profile is available create a default profile.
self.create_profile(profile_name)
# Recheck if the reaction of the default profile was successful
profile = self.session.query(MidiConfigurationProfileDbORM).filter_by(profile=profile_name).first()
if not profile:
raise ProfileNotFoundException(f"Profile '{profile_name}' not found.")
# ------------------------------------------------------------------------------
# Wrap some of the ORM static get methods
@staticmethod
def get_midi_config_properties_key_list() -> List[str]:
"""
Retrieve a list of MIDI configuration property keys.
:return: List of property keys.
"""
return MidiConfigurationProfileDbORM.get_midi_config_properties_key_list()
@staticmethod
def get_midi_event_action_key_list() -> List[str]:
"""
Retrieve a list of MIDI event action keys.
:return: List of event action keys.
"""
return MidiConfigurationProfileDbORM.get_midi_event_action_key_list()
def copy_event_action_mapping_dict(self) -> dict:
"""
Creates a deep copy of the event action mappings dictionary.
:return: A deep copy of the event action mappings dictionary.
"""
return copy.deepcopy(self.event_action_mappings)
# ------------------------------------------------------------------------------
# TODO: (commit to design decision) this could also be integrated in the class,
# but we will have to pass handles around to access the method0
def get_midi_configuration():
# Get the midi configuration state
profile_manager = MidiProfileManager()
selected_profile_name = profile_manager.get_selected_profile_name()
# Get the midi configuration state as a dictionary.
midi_config = profile_manager.get_profile_state(selected_profile_name)
# Convert to a simple obj that can be accessed via dot notation
midi_config = SimpleNamespace(**midi_config)
return midi_config

View File

@ -0,0 +1,392 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
from functools import wraps
from PyQt5 import QtCore
from openlp.core.common.mixins import RegistryProperties
from openlp.core.ui.media import is_looping_playback
from openlp.plugins.midi.lib.types_definitions.midi_event_action_map import ActionType
def error_handling_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"EventStateManager | ERROR: An error occurred in {func.__name__}: {e}")
# Handle the error or re-raise
# raise
return None
return wrapper
def dict_diff(dict1, dict2):
diffs = {}
def compare(d1, d2, path=''):
for key in d1.keys() | d2.keys():
if isinstance(d1.get(key), dict) and isinstance(d2.get(key), dict):
compare(d1[key], d2[key], path + key + '.')
elif d1.get(key) != d2.get(key):
full_key = path + key
diffs[full_key] = {'from': d1.get(key), 'to': d2.get(key)}
compare(dict1, dict2)
return diffs
class EventStateManager(QtCore.QObject, RegistryProperties):
"""
Accessed by midi controllers to get status type information from the application
"""
# Listener callback
doEventCallbackSignal = QtCore.pyqtSignal([str, int])
# Triggers the Transmission callback
poller_changed = QtCore.pyqtSignal()
def __init__(self):
"""
Constructor for the midi controller poll builder class.
"""
super(EventStateManager, self).__init__()
self.event_callback_mapping = None
self._prev_state = None
self._state = None
self._variable_control_velocity_offset = 0
self._play_action_type = ActionType.TRIGGER
self.add_receive_event_callback_mappings()
self._triggered_ = {event: False for event in
['event_item_next', 'event_item_previous', 'event_slide_next', 'event_slide_previous']}
# Connect signals to respective slots
self.doEventCallbackSignal.connect(self._do_event_callback)
# TODO: i might have to use " live = Registry().get('live_controller') "
def set_variable_control_offset(self, offset: int = 0):
self._variable_control_velocity_offset = offset
def set_play_action_type(self, action_type: bool):
if action_type:
self._play_action_type = ActionType.TOGGLE
else:
self._play_action_type = ActionType.TRIGGER
# ---------------------------------- Used for event reception ----------------------------------
def add_receive_event_callback_mappings(self):
# Mapping of event names to their callbacks
self.event_callback_mapping = {
# Callback Group 1: Screen-related actions
'event_screen_show': self.event_screen_show_cb,
'event_screen_theme': self.event_screen_theme_cb,
'event_screen_blank': self.event_screen_blank_cb,
'event_screen_desktop': self.event_screen_desktop_cb,
'event_clear_live': self.event_clear_live_cb,
# Callback Group 2: Video item actions
'event_video_play': self.event_video_play_cb,
'event_video_pause': self.event_video_pause_cb,
'event_video_stop': self.event_video_stop_cb,
'event_video_loop': self.event_video_loop_cb,
'event_video_seek': self.event_video_seek_cb,
'event_video_volume': self.event_video_volume_cb,
# Callback Group 3: General item actions
'event_item_goto': self.event_item_goto_cb,
'event_item_next': self.event_item_next_cb,
'event_item_previous': self.event_item_previous_cb,
# Callback Group 4: Slide/Song-specific actions
'event_slide_goto': self.event_slide_goto_cb,
'event_slide_next': self.event_slide_next_cb,
'event_slide_previous': self.event_slide_previous_cb,
# Callback Group 5: Song-specific transpose actions
'event_transpose_up': self.event_transpose_up_cb,
'event_transpose_down': self.event_transpose_down_cb
}
def _do_event_callback(self, event_key, value=None):
# Locate the correct event and call the event
callback = self.event_callback_mapping.get(event_key)
if callback:
callback(value)
else:
print(f"EventStateManager | No callback found for event key: {event_key}")
# Handle the case where no callback is found for the gi
# ---------------------------------- Used for event transmission ----------------------------------
def get_openlp_state(self):
LC = self.live_controller
MC = self.media_controller
state = {
# Other state changes
'counter': LC.slide_count if LC.slide_count else 0,
'service_id': self.service_manager.service_id,
'chordNotation': self.settings.value('songs/chord notation'),
# TODO: those sections need to be finished!
# TODO: It would be a good to have a test to check if all states are matching
# 'twelve': self.settings.value('api/twelve hour'),
# 'isSecure': self.settings.value('api/authentication enabled'),
# MIDI event action Group 1: Screen-related actions
'event_screen_show': LC.show_screen.isChecked(),
'event_screen_theme': LC.theme_screen.isChecked(),
'event_screen_blank': LC.blank_screen.isChecked(),
'event_screen_desktop': LC.desktop_screen.isChecked(),
'event_clear_live': True if LC.service_item else False,
# MIDI event action Group 2: Video item actions
'event_video_play': LC.media_info.is_playing,
'event_video_pause': not LC.media_info.is_playing and LC.controller_type in MC.current_media_players,
'event_video_stop': LC.controller_type not in MC.current_media_players,
'event_video_loop': self.is_video_loop(),
'event_video_seek': self.get_current_video_position(),
'event_video_volume': self.get_audio_level(),
# MIDI event action Group 3: General item actions
'event_item_goto': self.get_event_item_goto(),
'event_item_next': self._triggered_['event_item_next'],
'event_item_previous': self._triggered_['event_item_previous'],
# MIDI event action Group 4: Slide/Song-specific actions
'event_slide_goto': self.live_controller.selected_row + self._variable_control_velocity_offset or 0,
'event_slide_next': self._triggered_['event_slide_next'],
'event_slide_previous': self._triggered_['event_slide_previous'],
# Callback Group 5: Song-specific transpose actions
# TODO: maybe we need a goto for the transpose
'event_transpose_up': None, # TODO: Needs an implementation
'event_transpose_reset': None, # TODO: Needs an implementation
'event_transpose_down': None, # TODO: Needs an implementation
}
# Reset Triggers
self._triggered_ = {event: False for event in
['event_item_next', 'event_item_previous', 'event_slide_next', 'event_slide_previous']}
return state
def get_state_diff(self): # The internal state variable will not be updated
A = self._prev_state.copy()
B = self._state.copy()
diff = dict_diff(A, B)
return diff
def hook_signals(self):
self.live_controller.slidecontroller_changed.connect(self.on_signal_received_for_state_change)
self.service_manager.servicemanager_changed.connect(self.on_signal_received_for_state_change)
self.media_controller.vlc_live_media_tick.connect(self.on_video_position_change)
def unhook_signals(self):
try:
self.live_controller.slidecontroller_changed.disconnect(self.on_signal_received_for_state_change)
self.service_manager.servicemanager_changed.disconnect(self.on_signal_received_for_state_change)
self.media_controller.vlc_live_media_tick.disconnect(self.on_video_position_change)
except Exception:
pass
def post_initialization_get_state(self):
if self._prev_state is None:
self._state = self.get_openlp_state()
@QtCore.pyqtSlot(list)
@QtCore.pyqtSlot(str)
@QtCore.pyqtSlot()
def on_signal_received_for_state_change(self):
self._prev_state = self._state.copy()
self._state = self.get_openlp_state()
self.poller_changed.emit()
def on_video_position_change(self):
if (self.get_current_video_position() != self._state['event_video_seek'] or
self.get_audio_level() != self._state['event_video_volume']):
# Call a signal only when there is actual change
self.on_signal_received_for_state_change()
# ================================ List of state check callbacks ================================
def get_current_live_item_type(self): # TODO: will that actually be used or should it be removed
"""
Get the type of the currently live item.
"""
live_item = self.live_controller.service_item
return type(live_item).__name__ if live_item else None
@error_handling_decorator
def is_video_loop(self):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
return is_looping_playback(self.live_controller)
@error_handling_decorator
def get_current_video_position(self):
"""
Get the current playback position in milliseconds.
:return: Current playback position in milliseconds, or -1 if no media is loaded.
"""
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
# if self.live_controller.vlc_media_player.is_playing():
L = self.live_controller.vlc_media_player.get_length()
if L < 0:
return 0
t = self.live_controller.vlc_media_player.get_time()
val = 127 * (t / L)
val = round(val)
return val
else:
return 0
def get_audio_level(self):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
volume = self.live_controller.vlc_media_player.audio_get_volume()
return volume if volume >= 0 else 0
else:
return 0
@error_handling_decorator
def get_event_item_goto(self):
live_item = self.live_controller.service_item
if live_item:
for index, item in enumerate(self.service_manager.service_items):
if item['service_item'] == live_item:
return index + self._variable_control_velocity_offset # Position of live item in the service
return 0
# ================================ List of event action callbacks ================================
# ---------------------- # Callback Group 1: Screen-related actions ----------------------
@error_handling_decorator
def event_screen_show_cb(self, vel):
self.live_controller.slidecontroller_toggle_display.emit('show')
@error_handling_decorator
def event_screen_theme_cb(self, vel):
if vel > 0:
self.live_controller.slidecontroller_toggle_display.emit('theme')
else:
self.live_controller.slidecontroller_toggle_display.emit('show')
@error_handling_decorator
def event_screen_desktop_cb(self, vel):
if vel > 0:
self.live_controller.slidecontroller_toggle_display.emit('desktop')
else:
self.live_controller.slidecontroller_toggle_display.emit('show')
@error_handling_decorator
def event_screen_blank_cb(self, vel):
# if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
if vel > 0:
self.live_controller.slidecontroller_toggle_display.emit('blank')
else:
self.live_controller.slidecontroller_toggle_display.emit('show')
@error_handling_decorator
def event_clear_live_cb(self, vel):
self.event_video_stop_cb(vel)
self.live_controller.slidecontroller_live_clear.emit()
# ---------------------- # Callback Group 2: Video item actions ----------------------
@error_handling_decorator
def event_video_play_cb(self, vel):
# This is toggle mode for the play button
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
if self._play_action_type == ActionType.TOGGLE and vel == 0:
self.live_controller.mediacontroller_live_pause.emit()
else:
self.live_controller.mediacontroller_live_play.emit()
# TODO : consider resetting when not showing from MC.media_reset
@error_handling_decorator
def event_video_pause_cb(self, vel):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
self.live_controller.mediacontroller_live_pause.emit()
@error_handling_decorator
def event_video_stop_cb(self, vel):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
self.live_controller.mediacontroller_live_stop.emit()
@error_handling_decorator
def event_video_loop_cb(self, vel):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
self.media_controller.media_loop(self.live_controller)
@error_handling_decorator
def event_video_seek_cb(self, vel):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
p = (vel - self._variable_control_velocity_offset) / (127 - self._variable_control_velocity_offset)
L = self.live_controller.vlc_media_player.get_length()
time = round(p * L, None)
print(f" => P={p * 100}% L={L} time={time}")
self.media_controller.media_seek(self.live_controller, time)
@error_handling_decorator
def event_video_volume_cb(self, vel):
if self.live_controller.service_item and self.live_controller.service_item.name == 'media':
self.media_controller.media_volume(self.live_controller, vel)
# ---------------------- # MIDI event action Group 3: General item actions ----------------------
@error_handling_decorator
def event_item_previous_cb(self, vel):
self.service_manager.servicemanager_previous_item.emit()
@error_handling_decorator
def event_item_next_cb(self, vel):
self.service_manager.servicemanager_next_item.emit()
@error_handling_decorator
def event_item_goto_cb(self, vel):
vel = vel - self._variable_control_velocity_offset
self.service_manager.servicemanager_set_item.emit(vel)
# ---------------------- # MIDI event action Group 4: Slide/Song-specific actions ----------------------
@error_handling_decorator
def event_slide_previous_cb(self, vel):
self.live_controller.slidecontroller_live_previous.emit()
@error_handling_decorator
def event_slide_next_cb(self, vel):
self.live_controller.slidecontroller_live_next.emit()
@error_handling_decorator
def event_slide_goto_cb(self, vel):
vel = vel - self._variable_control_velocity_offset
self.live_controller.slidecontroller_live_set.emit([vel])
# ---------------------- # Callback Group 5: Song-specific transpose actions ----------------------
@error_handling_decorator
def event_transpose_up_cb(self, vel):
pass
@error_handling_decorator
def event_transpose_reset_cb(self, vel):
pass
@error_handling_decorator
def event_transpose_down_cb(self, vel):
pass

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################

View File

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`midi_receiver_service` module contains the MIDI receiver service. This service listens for MIDI events in real
time and performs necessary actions.
"""
import logging
import mido
from openlp.core.threading import ThreadWorker
from openlp.plugins.midi.lib.midi.midi_listener_template import MidiListenerTemplate
log = logging.getLogger(__name__)
class MidiEventListener(MidiListenerTemplate, ThreadWorker):
"""
A special Qt thread class to allow the MIDI listener to run at the same time as the UI.
"""
def __init__(self, on_receive_callback_ref):
ThreadWorker.__init__(self)
self._event_callback = on_receive_callback_ref
self.restart_listener_flag = False
self.exit_listener_flag = False
self.listening_is_active_flag = False
self.device_not_available_timeout = 1 # wait for that many seconds before rechecking
self._BLOCKING_LOOP_ = True # TODO: for testing!!!
def connect_to_device(self, input_midi_device=None):
"""
Exits if no MIDI input ports are available or if the selected MIDI input device is not available.
Can be flagged to restart, in which case it will stop listening and exit the loop.
"""
# Check for restart flag early
if self.restart_listener_flag:
return False
self.unpacked_midi_configuration()
# List available MIDI input ports
input_ports = MidiEventListener.get_input_midi_device_list()
log.info("Available MIDI input ports:", input_ports)
# If no input ports are available, exit
if not input_ports:
log.error("No MIDI input ports found.")
return False
# Use the device specified in the profile configuration
if input_midi_device is not None:
self.midi_config.input_midi_device = input_midi_device
# Check if the selected MIDI input device is available
if self.midi_config.input_midi_device not in input_ports:
log.error(f"The selected MIDI input device '{self.midi_config.input_midi_device}' is not available.")
return False
self.listening_is_active_flag = True
return True
def start(self):
self.retry_to_connect_and_listen()
def listen_for_midi_events(self):
"""
Continuously listens for MIDI events on the selected MIDI input device and processes incoming MIDI messages.
Can be flagged to restart, in which case it will stop listening and exit the loop.
"""
if not self.listening_is_active_flag:
return
try:
with mido.open_input(self.midi_config.input_midi_device) as inport:
self.inport = inport # TODO: we can attempt to close
log.info(f"Listening to MIDI port: {inport.name}")
print(f"Listening to MIDI port: {inport.name}")
if self.restart_listener_flag:
self.listening_is_active_flag = False
log.info("Stopped listening to MIDI port.")
return
if self._BLOCKING_LOOP_:
for message in inport:
# Handle the message here
self.handle_midi_event(message)
if self.restart_listener_flag:
break
else:
while self.listening_is_active_flag and not self.restart_listener_flag:
for message in inport.iter_pending():
self.handle_midi_event(message)
except Exception as e:
log.error(f"Error occurred while listening to MIDI port: {e}")
finally:
self.listening_is_active_flag = False
log.info("Stopped listening to MIDI port.")
def listen_for_a_single_midi_event(self):
"""
Listen for MIDI events on the selected MIDI input device and return the first incoming MIDI message.
"""
if not self.listening_is_active_flag:
return None
try:
with mido.open_input(self.midi_config.input_midi_device) as inport:
log.info(f"Listening to MIDI port: {inport.name}")
print(f"Listening to MIDI port: {inport.name}")
if self._BLOCKING_LOOP_:
for message in inport:
if self.exit_listener_flag:
break # Stop listening if the flag is set
return message
else:
while self.listening_is_active_flag and not self.exit_listener_flag:
for message in inport.iter_pending():
return message
except Exception as e:
print(f"Error occurred while listening to MIDI port: {e}") # TODO: cleanup
log.error(f"Error occurred while listening to MIDI port: {e}")
finally:
self.listening_is_active_flag = False
log.info("Stopped listening to MIDI port.")
def stop(self):
"""
Request to exit the MIDI listener.
"""
log.info("Request received to stop the MIDI listener.") # TODO:
if self.inport: # TODO: we can attempt to close
self.inport.close()
self.request_exit()
self.quit.emit()
self.listening_is_active_flag = False
self.send_unblocking_dummy_event()
log.info("MIDI listener stop end of method.") # TODO:
def send_unblocking_dummy_event(self):
try:
midi_output = mido.open_output(self.midi_config.output_midi_device)
try:
unblocking_midi_message = mido.Message('active_sensing')
midi_output.send(unblocking_midi_message)
except Exception as e:
log.error(f"Error sending unblocking MIDI message: {e}")
finally:
midi_output.close() # Ensure the output is closed after sending
except Exception as e:
if self.midi_config is not None:
log.error(f"Error opening MIDI output device '{self.midi_config.output_midi_device}': {e}")
else:
log.info(f"The midi configuration is empty which caused: {e}")
def request_restart(self):
"""
Request to restart the MIDI listener.
"""
self.restart_listener_flag = True
self.send_unblocking_dummy_event()
def __str__(self):
return 'MidiEventListener_worker_v1'

View File

@ -0,0 +1,33 @@
import time
from typing import Callable
from openlp.core.threading import ThreadWorker # , run_thread, make_remove_thread # TODO: might be used, if not cleanup
from openlp.plugins.midi.lib.midi.mido import MidiEventListener as Selected_MidiEventListener
MidiEventListener = Selected_MidiEventListener
# Service <= ThreadWorker Class <= Listener Async class
class MidiEventListener_DISABLE(ThreadWorker):
def __init__(self, event_callback_reference: Callable):
ThreadWorker.__init__(self)
self.worker_thread = Selected_MidiEventListener(on_receive_callback_ref=event_callback_reference)
self.running = False
def start(self):
self.running = True
self.worker_thread.start()
while self.running:
time.sleep(0.1) # Adjust the sleep time as needed
print("Exit WORKER THREAD START METHOD")
def stop(self):
self.running = False
time.sleep(0.2)
self.worker_thread.stop()
print("IN WORKER THREAD METHOD STOP ")
time.sleep(2)
print("IN WORKER THREAD METHOD STOP after pause")
def __str__(self):
return 'MidiEventListener_INSEPTION'

View File

@ -0,0 +1,193 @@
from abc import abstractmethod # ABC, # TODO: abstraction could be used
from typing import Callable
import time
from threading import Thread
from openlp.plugins.midi.lib.handlers_managers.device_handler import MidiDeviceHandler
from openlp.plugins.midi.lib.handlers_managers.profile_db_manager import get_midi_configuration
from openlp.plugins.midi.lib.types_definitions.constants import midi_ch_any
class MidiListenerTemplate():
def __init__(self, on_receive_callback_ref: Callable):
"""
Initialize the MIDI listener with a callback for MIDI events.
:param on_receive_callback_ref: A callback function that will be called with each MIDI event.
"""
self.thread = None
self._event_callback = on_receive_callback_ref
self.transmit_callback = None
# Device connection retry time out
self.device_not_available_timeout = 1 # wait for that many seconds before rechecking
self.listening_is_active_flag = False
self.exit_listener_flag = False
self.receiver_disabled_flag = False
# MIDI configuration
self.midi_config = None
# Shorthand mappings
self.mappings = None
@abstractmethod
def connect_to_device(self, device: str) -> bool:
"""
Establish a connection to a specified MIDI device and port.
"""
pass
@abstractmethod
def listen_for_a_single_midi_event(self):
"""
Listen for MIDI events on the selected MIDI input device and return the first incoming MIDI message.
"""
pass
@abstractmethod
def listen_for_midi_events(self):
"""
Continuously listen for MIDI messages.
"""
pass
@abstractmethod
def stop(self):
"""
Stop listening for MIDI messages.
"""
pass
@abstractmethod
def stop_from_inside(self):
"""
Stop listening for MIDI messages but doesn't stop the thread. Used for MIDI mapping assignments.
"""
pass
# ======= Methods to inherit =======
def is_disabled(self):
return self.receiver_disabled_flag
def _async_pause(self, pause_duration=0.01):
"""
Pause the thread execution asynchronously for a specified duration.
:param pause_duration: Duration in seconds for each pause cycle. Default is 0.1 seconds.
"""
# NOTE: TO AVOID 100% THREAD UTILIZATION DO AN UGLY PAUSE
time.sleep(pause_duration)
@staticmethod
def get_input_midi_device_list() -> list:
"""
Return a list of available MIDI input devices.
"""
return MidiDeviceHandler.get_input_midi_devices()
def start(self):
self.thread = Thread(target=self.retry_to_connect_and_listen)
self.thread.start()
def get_single_midi_event_thread_start(self):
self.thread = Thread(target=self.listen_for_a_single_midi_event)
self.thread.start()
def retry_to_connect_and_listen(self):
while not self.connect_to_device() and not self.exit_listener_flag and not self.is_disabled():
print(f"MidiListenerTemplate | Retrying to connect to in {self.device_not_available_timeout} seconds...")
time.sleep(self.device_not_available_timeout)
if self.listening_is_active_flag:
self.listen_for_midi_events()
def request_exit(self):
self.exit_listener_flag = True
def request_restart(self):
if self.listening_is_active_flag:
self.stop() # Stop the current listening process
self.start() # Start a new listening process
def handle_midi_event(self, midi_message):
# Filter messages based on the selected channel
if ((self.midi_config.input_device_channel == midi_ch_any or
(midi_message.channel + 1) == self.midi_config.input_device_channel)):
# Get the MIDI data value, i.e. Note or CC or Program etc.
midi_data_1 = self.extract_midi_data1(midi_message)
velocity_or_value = self.extract_midi_data2(midi_message)
# Check for event matches
matching_events = []
for event_key, event in self.mappings.items():
if event.midi_type == midi_message.type and event.midi_data == midi_data_1:
matching_events.append(event)
# Check and validate the matching
if len(matching_events) == 1:
# Found exactly one matching event
print(f"MidiListenerTemplate | MIDI message detected {midi_message}")
self._event_callback(matching_events[0].mapping_key, velocity_or_value)
# We can just update only if the midi event was recognized
if self.midi_config.device_sync:
self.transmit_callback()
if len(matching_events) > 1:
# We should never really come here if the mapping is unique as it should be.
raise ValueError("MidiListenerTemplate | Multiple events found for MIDI message.")
def unpacked_midi_configuration(self):
self.midi_config = get_midi_configuration()
# Extract only the mappings
self.mappings = {key: value for key, value in self.midi_config.__dict__.items() if key.startswith("event")}
# UI labels need to be formatted # TODO: again the direct conversion (ugly)
for key, value in self.mappings.items():
self.mappings[key].midi_type = value.midi_type.lower().replace(" ", "_")
if MidiDeviceHandler.should_sanitize_names():
self.midi_config.input_midi_device = (
MidiDeviceHandler.match_exact_input_device_name(self.midi_config.input_midi_device))
def extract_midi_data1(self, midi_message) -> int:
"""
Extracts the relevant MIDI data 1 from the message (e.g. note value, control CC parameter, program number, etc.)
"""
if hasattr(midi_message, 'note'):
return midi_message.note
elif hasattr(midi_message, 'control'):
return midi_message.control
elif hasattr(midi_message, 'program'):
return 0 # NOTE: here we return a static value to identify the control type.
elif hasattr(midi_message, 'pitch'):
return 0 # NOTE: here we return a static value to identify the control type.
else:
raise NotImplementedError(f'MidiEventListener | MIDI conversion not implemented for {midi_message}.')
def extract_midi_data2(self, midi_message) -> int:
"""
Extracts the relevant MIDI data 2 (like note velocity or control CC value ) from a mido message.
"""
if hasattr(midi_message, 'velocity'):
return midi_message.velocity
elif hasattr(midi_message, 'value'):
return midi_message.value
# NOTE: The program and pitch are technically not data2.
# However, in terms of control, here it's more useful as such!
# Because data2 is the control variable similar to velocity.
# TODO: we should probably reflect that in the UI assignment
elif hasattr(midi_message, 'program'):
return midi_message.program
elif hasattr(midi_message, 'pitch'):
return midi_message.pitch
# Add more cases as needed for different message types
else:
print(f'MidiEventListener | MIDI conversion not implemented or supported for {midi_message}.')
return 0 # Default value for messages without a 'data2' equivalent
@abstractmethod
def __str__(self):
return 'MidiEventListenerTemplate'

View File

@ -0,0 +1,79 @@
import mido
from openlp.plugins.midi.lib.midi.midi_listener_template import MidiListenerTemplate
from openlp.plugins.midi.lib.types_definitions.constants import openlp_midi_device, disabled_midi_device
class MidiEventListener(MidiListenerTemplate):
def __init__(self, on_receive_callback_ref):
super().__init__(on_receive_callback_ref)
self.inport = None
self.thread = None
self.listening_is_active_flag = False
def connect_to_device(self, device: str = None) -> bool:
self.unpacked_midi_configuration()
if device is None:
device = self.midi_config.input_midi_device
if device == disabled_midi_device['input']:
self.receiver_disabled_flag = True
return False
# Create and connect to own virtual device
if device == openlp_midi_device['gui_label']:
try:
self.inport = mido.open_input(name=openlp_midi_device['name'], virtual=True) # TODO: hardcode some more
print("MidiEventListener | Virtual MIDI device created.")
return True
except Exception as e:
print(f"MidiEventListener | Failed to create virtual MIDI device. Error: {e}")
return False
try:
self.inport = mido.open_input(device) # Assuming 'device' is the name of the MIDI input port
self.listening_is_active_flag = True
print(f"MidiEventListener | Listening to MIDI port: {self.inport.name}")
return True
except IOError as e:
print(f"MidiEventListener | Failed to connect to {device}. Error {e}")
return False
def listen_for_midi_events(self):
# TODO: NOTE itis not tested if this helps mitigate any errors. The try bock might have to be removed.
try: # TODO: consider having extra redundancy in the other listener implementations
while self.listening_is_active_flag:
for msg in self.inport.iter_pending():
self.handle_midi_event(msg)
self._async_pause()
except Exception as e:
print(f"MidiEventListener | Error: Midi Receiver error detected {e}")
self.request_restart()
def listen_for_a_single_midi_event(self):
while self.listening_is_active_flag:
for msg in self.inport.iter_pending():
self._event_callback(msg)
return # Return after the message is processed
self._async_pause(0.001)
def stop(self):
print("MidiEventListener| Request received to stop the MIDI listener.")
self.request_exit()
self.listening_is_active_flag = False
if self.thread and self.thread.is_alive():
self.thread.join()
if self.inport:
self.inport.close()
print("MidiEventListener | Stop the MIDI listener. End of method")
def stop_from_inside(self):
print("MidiEventListener | Request received to stop the MIDI listener from INSIDE.")
self.request_exit()
self.listening_is_active_flag = False
self._async_pause(0.005)
if self.inport:
self.inport.close()
print("MidiEventListener | Stop the MIDI listener. End of method from INSIDE")
def __str__(self):
return 'MidiEventListener-MIDO'

View File

@ -0,0 +1,120 @@
import pygame.midi
from openlp.plugins.midi.lib.midi.midi_listener_template import MidiListenerTemplate
from collections import namedtuple
from openlp.plugins.midi.lib.types_definitions.constants import openlp_midi_device, disabled_midi_device
# Define a simple MIDI message structure similar to Mido
MidiMessage = namedtuple('MidiMessage', ['type', 'note', 'velocity', 'channel'])
def convert_pygame_midi_to_mido(pygame_midi_data):
"""
Convert Pygame MIDI data to a format similar to Mido's Message.
This is a basic example and might need adjustments based on the specific MIDI data format.
"""
status_byte = pygame_midi_data[0][0][0]
data_bytes = pygame_midi_data[0][0][1:]
# Extract MIDI message components
channel = status_byte & 0x0F
status = status_byte & 0xF0
# Define MIDI message type based on the status byte
if status == 0x90: # Note On
message_type = 'note_on'
elif status == 0x80: # Note Off
message_type = 'note_off'
else:
message_type = 'unknown'
# TODO : a full list should be implemented here!!!!!
# Assuming standard Note On/Off messages with two data bytes: note and velocity
note = data_bytes[0] if len(data_bytes) > 0 else None
velocity = data_bytes[1] if len(data_bytes) > 1 else None
return MidiMessage(type=message_type, note=note, velocity=velocity, channel=channel)
class MidiEventListener(MidiListenerTemplate):
def __init__(self, on_receive_callback_ref):
super().__init__(on_receive_callback_ref)
pygame.midi.init()
self.midi_in = None
self.thread = None
self.listening_is_active_flag = False
def connect_to_device(self, device: str = None):
self.unpacked_midi_configuration()
if device is None:
device = self.midi_config.input_midi_device
if device == disabled_midi_device['input']:
self.receiver_disabled_flag = True
return False
if device == openlp_midi_device['gui_label']: # Replace with your virtual device identifier
# Handle virtual device creation
# Note: pygame.midi might not support virtual MIDI devices directly
print("Virtual MIDI device creation is not supported by pygame.midi.")
return False
device_id = self._get_device_id_by_name(device)
if device_id is not None:
try:
self.midi_in = pygame.midi.Input(device_id)
self.listening_is_active_flag = True
return True
except Exception as e:
print(f"Failed to connect to {device}. Error: {e}")
return False
else:
print(f"Device '{device}' not found.")
return False
def listen_for_midi_events(self):
while self.listening_is_active_flag:
if self.midi_in.poll():
pygame_midi_message = self.midi_in.read(1)
self.handle_midi_event(convert_pygame_midi_to_mido(pygame_midi_message))
self._async_pause()
def listen_for_a_single_midi_event(self):
while self.listening_is_active_flag:
if self.midi_in.poll():
pygame_midi_message = self.midi_in.read(1)
self._event_callback(convert_pygame_midi_to_mido(pygame_midi_message))
return # Return after the message is processed
self._async_pause(0.001)
def stop(self):
print("Request received to stop the MIDI listener.")
self.request_exit()
self.listening_is_active_flag = False
if self.thread and self.thread.is_alive():
self.thread.join()
if self.midi_in:
self.midi_in.close()
print("Stop the MIDI listener. End of method")
def stop_from_inside(self):
print("Request received to stop the MIDI listener from INSIDE.")
self.request_exit()
self.listening_is_active_flag = False
self._async_pause(0.02)
if self.midi_in:
self.midi_in.close()
print("Stop the MIDI listener. End of method from INSIDE")
def _get_device_id_by_name(self, name):
device_count = pygame.midi.get_count()
for i in range(device_count):
info = pygame.midi.get_device_info(i)
if info[1].decode() == name and info[2] == 1:
return i
return None
def __str__(self):
return 'PygameMidiEventListener'

View File

@ -0,0 +1,124 @@
import rtmidi
from collections import namedtuple
import sys
from openlp.plugins.midi.lib.midi.midi_listener_template import MidiListenerTemplate
from openlp.plugins.midi.lib.types_definitions.constants import openlp_midi_device, disabled_midi_device
# Define a simple MIDI message structure similar to Mido
MidiMessage = namedtuple('MidiMessage', ['type', 'note', 'velocity', 'channel'])
def convert_rtmidi_to_mido(rtmidi_data):
"""
Convert rtmidi MIDI data to a format similar to Mido's Message.
"""
midi_data, _ = rtmidi_data
status_byte = midi_data[0]
data_bytes = midi_data[1:]
# Extract MIDI message components
channel = status_byte & 0x0F
status = status_byte & 0xF0
# Define MIDI message type based on the status byte
if status == 0x90: # Note On
message_type = 'note_on'
elif status == 0x80: # Note Off
message_type = 'note_off'
else:
message_type = 'unknown'
# TODO: here and in py game we have a similar thing.
# TODO...: We should unify booth methods and put the in the MIDI definitions class
# Assuming standard Note On/Off messages with two data bytes: note and velocity
note = data_bytes[0] if len(data_bytes) > 0 else None
velocity = data_bytes[1] if len(data_bytes) > 1 else None
return MidiMessage(type=message_type, note=note, velocity=velocity, channel=channel)
class MidiEventListener(MidiListenerTemplate):
def __init__(self, on_receive_callback_ref):
super().__init__(on_receive_callback_ref)
self.midi_in = None
self.thread = None
self.listening_is_active_flag = False
def connect_to_device(self, device: str = None):
self.unpacked_midi_configuration()
if device is None:
device = self.midi_config.input_midi_device
if device == disabled_midi_device['input']:
self.receiver_disabled_flag = True
return False
self.midi_in = rtmidi.MidiIn()
available_ports = self.midi_in.get_ports()
# Virtual port creation (if supported)
if device == openlp_midi_device['gui_label']:
if sys.platform != "win32": # Virtual ports not supported on Windows
try:
self.midi_in.open_virtual_port(openlp_midi_device['name'])
print("Virtual MIDI device created.")
self.listening_is_active_flag = True
return True
except Exception as e:
print(f"Failed to create virtual MIDI device. Error: {e}")
return False
else:
print("Virtual MIDI ports are not supported on Windows.")
return False
# Connect to a physical MIDI device
if device in available_ports:
try:
port_index = available_ports.index(device)
self.midi_in.open_port(port_index)
self.listening_is_active_flag = True
print(f"Listening to MIDI port: {device}")
return True
except Exception as e:
print(f"Failed to connect to {device}. Error: {e}")
return False
else:
print(f"Device '{device}' not found.")
return False
def listen_for_midi_events(self):
while self.listening_is_active_flag:
rtmidi_message = self.midi_in.get_message()
if rtmidi_message:
self.handle_midi_event(convert_rtmidi_to_mido(rtmidi_message))
self._async_pause(1 / 1000) # 1ms
def listen_for_a_single_midi_event(self):
while self.listening_is_active_flag:
rtmidi_message = self.midi_in.get_message()
if rtmidi_message:
self._event_callback(convert_rtmidi_to_mido(rtmidi_message))
return # Return after the message is processed
self._async_pause(1 / 1000) # 1ms
def stop(self):
print("Request received to stop the MIDI listener.")
self.request_exit()
self.listening_is_active_flag = False
if self.thread and self.thread.is_alive():
self.thread.join()
if self.midi_in:
self.midi_in.close_port()
print("Stop the MIDI listener. End of method")
def stop_from_inside(self):
print("Request received to stop the MIDI listener from INSIDE.")
self.request_exit()
self.listening_is_active_flag = False
self._async_pause(0.02)
if self.midi_in:
self.midi_in.close_port()
print("Stop the MIDI listener. End of method from INSIDE")
def __str__(self):
return 'PythonRtMidiEventListener'

View File

@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import logging
import time
import mido
from openlp.plugins.midi.lib.handlers_managers.device_handler import MidiDeviceHandler
from openlp.plugins.midi.lib.handlers_managers.profile_db_manager import get_midi_configuration
from openlp.plugins.midi.lib.types_definitions.constants import disabled_midi_device
from openlp.plugins.midi.lib.types_definitions.midi_event_action_map import ActionType
class MidiEventTransmitter:
def __init__(self):
self.midi_config = None
self.midi_output = None
self.transmitter_disabled_flag = False
self.reset_transmitter_requested_flag = False
self.initialize_transmitter()
def initialize_transmitter(self):
"""
Initialize or reinitialize the MIDI configuration and output.
"""
self.midi_config = get_midi_configuration()
if self.midi_config.output_midi_device == disabled_midi_device['output']:
self.transmitter_disabled_flag = True
return
self.close_midi_output() # Ensure previous connections are closed
self.connect_to_device()
def is_disabled(self) -> bool:
return self.transmitter_disabled_flag
def connect_to_device(self):
"""
Connect to the MIDI output device.
"""
if MidiDeviceHandler.should_sanitize_names():
self.midi_config.output_midi_device = (
MidiDeviceHandler.match_exact_output_device_name(self.midi_config.output_midi_device))
try:
self.midi_output = mido.open_output(self.midi_config.output_midi_device)
logging.info(f"Connected to MIDI output device: {self.midi_config.output_midi_device}")
print(f"MidiEventTransmitter | Transmitting on MIDI port: {self.midi_output.name}")
except Exception as e:
logging.error(f"Failed to connect to MIDI output device: {e}")
def handle_state_change(self, state_diff):
"""
Handles the state change by sending appropriate MIDI messages.
Checks for reset request before proceeding.
"""
if self.is_disabled():
return
if self.reset_transmitter_requested_flag:
self.initialize_transmitter()
self.reset_transmitter_requested_flag = False
if not self.midi_output:
logging.warning("MIDI output device is not connected. Cannot transmit MIDI events.")
self.reset_transmitter_requested_flag = True # Set to retry to reconnect when there is new state change
return
print(f"MidiEventTransmitter | State difference {state_diff}")
# TODO: Implement the logic to convert state_diff to MIDI messages and send them
for event_type in state_diff:
midi_message = self.state_change_to_midi_message(event_type, state_diff[event_type])
if midi_message:
self.send_midi_message(midi_message)
def state_change_to_midi_message(self, event_type, change):
"""
Converts a state change to a MIDI message.
This method is a placeholder and should be tailored to your application's specific needs.
"""
# Define variables
mapping = None
vMax, vMin = 127, 0
to_value = change['to']
midi_message = None
# TODO: handle the "ANY" case. Maybe change any to "OMNI" or just send to channel 1
# Specify the MIDI channel (0-15 for channels 1-16)
_channel = int(self.midi_config.output_device_channel - 1)
if not ("event_" in event_type):
print(f"MidiEventTransmitter | Ignore state [{event_type}]. It is not a mapping.")
return midi_message
try:
mapping = getattr(self.midi_config, event_type)
except Exception:
logging.error(f"Event mapping not found for [{event_type}] !")
print(f"MidiEventTransmitter | ERROR: Event mapping not found for [{event_type}]!")
if mapping:
# Get the Message type
_midi_type = mapping.midi_type.lower().replace(" ", "_")
# The velocity will be handled depending on the type
velocity_or_value = vMin
if ActionType.TRIGGER == mapping.tx_action_type:
velocity_or_value = vMax if to_value else vMin
elif ActionType.TOGGLE == mapping.tx_action_type:
velocity_or_value = vMax if to_value else vMin
elif ActionType.VARIABLE == mapping.tx_action_type:
if vMin <= to_value <= vMax:
velocity_or_value = to_value
else:
logging.error(f"Event velocity error for [{event_type}]! Cannot map velocity!")
return midi_message
midi_message = self.create_midi_message(msg_type=_midi_type, channel=_channel,
data1=mapping.midi_data, data2=velocity_or_value)
event_type_disp = event_type.replace('_', ' ')
print(f"MidiEventTransmitter | Show {event_type_disp} changed to [{to_value}] with [{midi_message}]!")
return midi_message
def create_midi_message(self, msg_type, channel, data1, data2=None):
if msg_type in ['note_on', 'note_off']:
# Note messages require note and velocity
velocity = data2 if data2 is not None else 64 # Default velocity
return mido.Message(msg_type, note=data1, velocity=velocity, channel=channel)
elif msg_type == 'control_change':
# Control change messages require control number and value
value = data2 if data2 is not None else 0 # Default value
return mido.Message(msg_type, control=data1, value=value, channel=channel)
elif msg_type == 'program_change':
# Program change message requires a program number
return mido.Message(msg_type, program=data1, channel=channel)
elif msg_type == 'channel_pressure':
# TODO: dormant entry. IGNORE
# Channel pressure message requires a pressure value
return mido.Message(msg_type, pressure=data1, channel=channel)
elif msg_type == 'aftertouch':
# TODO: dormant entry. IGNORE
# Aftertouch message requires note and value
value = data2 if data2 is not None else 0 # Default value
return mido.Message(msg_type, note=data1, value=value, channel=channel)
elif msg_type == 'pitchwheel':
# Pitchwheel requires one data byte (expressed as a single 14-bit value)
return mido.Message(msg_type, pitch=data1, channel=channel)
elif msg_type == 'polyphonic_aftertouch':
# TODO: dormant entry. IGNORE
# Polyphonic aftertouch requires note and value
value = data2 if data2 is not None else 0 # Default value
return mido.Message(msg_type, note=data1, value=value, channel=channel)
elif msg_type == "..._disabled_...": # TODO: this is the reason we need a mapping method
return None
else:
raise ValueError(f"Unsupported MIDI message type: {msg_type}")
def send_midi_message(self, midi_message):
"""
Sends the given MIDI message to the output device.
"""
if self.midi_output:
try:
self.midi_output.send(midi_message)
except Exception as e:
# This will catch unexpected device disconnection events
print(f"MidiEventTransmitter | Error: Midi message was not successfully transmitted {e}")
self.request_reset()
def request_reset(self):
"""
Request a reset of the MIDI configuration and output connection.
"""
self.reset_transmitter_requested_flag = True
def close_midi_output(self):
"""
Close the MIDI output connection if it exists.
"""
if self.midi_output:
self.midi_output.close()
self.midi_output = None
def close(self):
"""
Close the MIDI output connection when done.
"""
self.close_midi_output()
# -------------------------------------- Device reset methods --------------------------------------
def generate_midi_space(self, channels, midi_types, notes_controls, velocities, send=False, store=False, pause=0):
all_messages = []
start_time = time.time()
print("MidiEventTransmitter | Starting generate_midi_space method...")
# This will give a cool effect if we put the velocity more towards the top of the loop nesting
for channel in channels:
for msg_type in midi_types:
for velocity in velocities:
for note_control in notes_controls:
try:
midi_message = self.create_midi_message(
msg_type=msg_type,
channel=channel,
data1=note_control,
data2=velocity
)
if store:
all_messages.append(midi_message)
if send:
# print(midi_message)
self.send_midi_message(midi_message)
if pause:
time.sleep(pause)
except ValueError as e:
print(f"MidiEventTransmitter | ERROR Skipping unsupported message type or combination: {e}")
end_time = time.time()
elapsed_time = end_time - start_time
print(f"MidiEventTransmitter | Completed generate_midi_space method in {elapsed_time:.2f} seconds.")
return all_messages
def reset_device_midi_state(self):
if self.is_disabled() or not self.midi_output or not self.midi_config.reset_midi_state:
return
# Notes off reset
midi_types = ['note_on', 'note_off', 'control_change', 'program_change']
channels = [int(self.midi_config.output_device_channel - 1)] # range(16) # MIDI channels 0-15
notes_controls = range(128) # MIDI notes/controls 0-127
velocities = [0] # Simple reset to zero velocity/data2
# Do reset
self.generate_midi_space(channels, midi_types, notes_controls, velocities, send=True)
# Flashy effect
midi_types = ['note_on', 'control_change']
channels = [int(self.midi_config.output_device_channel - 1)] # range(16) # MIDI channels 0-15
notes_controls = range(128) # MIDI notes/controls 0-127
velocities = [i for i in range(128) if i % 32 == 0] + [127, 0]
# TODO: more fancy options # [0, 127, 0] # range(128) # MIDI velocities 0-127
# Do flashy effect
self.generate_midi_space(channels, midi_types, notes_controls, velocities, send=True)

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################

View File

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`db` module provides the database and schema that is the backend for the Midi plugin.
"""
from typing import List, Any
# Database imports
from sqlalchemy import Column
from sqlalchemy.orm import Session, declarative_base
from sqlalchemy.types import Integer, UnicodeText, Boolean
# Local imports
from openlp.core.db.helpers import init_db
from openlp.plugins.midi.lib.types_definitions.constants import default_midi_device, default_profile_name
from openlp.plugins.midi.lib.types_definitions.define_midi_event_action_mapping import get_default_action_midi_mappings
Base = declarative_base()
class ProfileExistsException(Exception):
"""
Custom exception raised when attempting to create a profile with a name which already exists in the database.
"""
pass
class ProfileNotFoundException(Exception):
"""
Custom exception raised when a specific profile name is not found in the database.
"""
pass
class MidiConfigurationProfileDbORM(Base):
"""
Represents the database schema for MIDI settings in the Midi plugin.
"""
__tablename__ = 'midi_settings'
# Configuration profiles: ID column for referencing
id: Column = Column(Integer, primary_key=True)
profile: Column = Column(UnicodeText, default=default_profile_name)
is_selected_profile: Column = Column(Boolean, default=True) # The profile is selected
# Device configuration for input & output
reset_midi_state: Column = Column(Boolean, default=False)
device_sync: Column = Column(Boolean, default=False)
deferred_control_mode: Column = Column(Boolean, default=False)
play_button_is_toggle: Column = Column(Boolean, default=False) # TODO: rename to => play_action_is_toggle
control_offset: Column = Column(Integer, default=0)
input_midi_device: Column = Column(UnicodeText, default=default_midi_device['input'])
input_device_channel: Column = Column(Integer, default=default_midi_device['input_channel'])
output_midi_device: Column = Column(UnicodeText, default=default_midi_device['output'])
output_device_channel: Column = Column(Integer, default=default_midi_device['output_channel'])
# Dynamically create MIDI event action columns
for mapping in get_default_action_midi_mappings():
locals()[mapping.mapping_key] = Column(UnicodeText, default=mapping.export_to_orm_string())
# ------------------------------------------------------------------------------------------
@classmethod
def get_midi_config_properties_key_list(cls) -> List[str]:
"""
Get a list of property names not related to MIDI events.
:return: List of other property names.
"""
return [column.name for column in cls.__table__.columns
if not column.name.startswith('event_') and column.name != 'id']
@classmethod
def get_midi_event_action_key_list(cls) -> List[str]:
"""
Get a list of property names related to MIDI events.
:return: List of property names related to MIDI events.
"""
return [column.name for column in cls.__table__.columns if column.name.startswith('event_')]
def set_property(self, column_name: str, value: Any) -> None:
"""
Set value for the given column.
:param column_name: Name of the column.
:param value: Value to be set.
"""
setattr(self, column_name, value)
# ------------------------------------------------------------------------------------------
def get_property(self, column_name: str) -> Any:
"""
Get value for the given column.
:param column_name: Name of the column.
:return: The value of the column.
"""
return getattr(self, column_name)
@classmethod
def get_all_profiles(cls, session: Session) -> List[str]:
"""
Get a list of all existing profiles.
:param session: Database session.
:return: List of profile names.
"""
return [profile.profile for profile in session.query(cls.profile).distinct()]
@classmethod
def create_profile(cls, session: Session, profile_name: str) -> None:
"""
Create a new profile.
:param session: Database session.
:param profile_name: Name of the profile to be created.
"""
if session.query(cls).filter_by(profile=profile_name).first():
raise ProfileExistsException(f"Profile '{profile_name}' already exists.")
new_profile = cls(profile=profile_name)
session.add(new_profile)
session.commit()
@classmethod
def delete_profile(cls, session: Session, profile_name: str) -> None:
"""
Delete an existing profile.
:param session: Database session.
:param profile_name: Name of the profile to be deleted.
"""
profile = session.query(cls).filter_by(profile=profile_name).first()
if not profile:
raise ProfileNotFoundException(f"Profile '{profile_name}' not found.")
session.delete(profile)
session.commit()
@classmethod
def rename_profile(cls, session: Session, old_name: str, new_name: str) -> None:
"""
Rename an existing profile.
:param session: Database session.
:param old_name: Current name of the profile.
:param new_name: New name for the profile.
"""
if session.query(cls).filter_by(profile=new_name).first():
raise ProfileExistsException(f"Profile '{new_name}' already exists.")
profile = session.query(cls).filter_by(profile=old_name).first()
if not profile:
raise ProfileNotFoundException(f"Profile '{old_name}' not found.")
profile.profile = new_name
session.commit()
def init_schema_midi_plugin_dtb(url: str) -> Session:
"""
Setup the midi database connection and initialise the database schema
:param url: The database location
"""
session, metadata = init_db(url, base=Base)
metadata.create_all(bind=metadata.bind, checkfirst=True)
return session

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
default_profile_name = 'Default Profile'
disabled_midi_device = dict()
disabled_midi_device['input'] = "... (Disabled) No input MIDI device selected ..."
disabled_midi_device['output'] = "... (Disabled) No output MIDI device selected ..."
# TODO: replace the values in the ORM
default_midi_device = dict()
default_midi_device['input'] = disabled_midi_device['input']
default_midi_device['input_channel'] = 15
default_midi_device['output'] = disabled_midi_device['output']
default_midi_device['output_channel'] = 16
openlp_midi_device = dict()
openlp_midi_device['gui_label'] = "=== OpenLP MIDI Input ==="
openlp_midi_device['name'] = 'OpenLP-MIDI-Input'
assignment_message = "== Waiting for MIDI input =="
midi_ch_any = "Any"

View File

@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
This module defines the default MIDI action mappings for the OpenLP Midi plugin.
Each mapping is defined as an instance of the MidiActionMapping class. These mappings
are used as the default settings for MIDI actions within the application.
"""
from typing import List
from openlp.plugins.midi.lib.types_definitions.midi_event_action_map import (MidiActionMapping,
ActionType as A, ModuleCategory as C)
from openlp.plugins.midi.lib.types_definitions.midi_definitions import MIDI_Def
# Shorthand alias for message types
M = MIDI_Def.map_midi_msg_types_var_to_str_id()
default_action_midi_mappings = [
# Group 1: Screen-related actions
MidiActionMapping('event_screen_show', 'Screen Show', C.SCREEN, A.TRIGGER,
M.NOTE_ON, 12, 'Toggle the visibility of the screen'),
MidiActionMapping('event_screen_theme', 'Screen Theme', C.SCREEN, A.TOGGLE,
M.NOTE_ON, 14, 'Toggle the theme screen on or off'),
MidiActionMapping('event_screen_blank', 'Screen Blank', C.SCREEN, A.TOGGLE,
M.NOTE_ON, 16, 'Toggle blank screen on or off'),
MidiActionMapping('event_screen_desktop', 'Screen Desktop', C.SCREEN, A.TOGGLE,
M.NOTE_ON, 17, 'Toggle desktop screen on or off'),
MidiActionMapping('event_clear_live', 'Clear Live', C.SCREEN, A.TRIGGER,
M.NOTE_ON, 18, 'Clear the live to empty'),
# Group 2: Video item actions
MidiActionMapping('event_video_play', 'Video Play', C.VIDEO, A.TRIGGER,
M.NOTE_ON, 24, 'Control video playback'),
MidiActionMapping('event_video_pause', 'Video Pause', C.VIDEO, A.TRIGGER,
M.NOTE_ON, 26, 'Pause video playback'),
MidiActionMapping('event_video_stop', 'Video Stop', C.VIDEO, A.TRIGGER,
M.NOTE_ON, 28, 'Stop video playback'),
MidiActionMapping('event_video_loop', 'Video Loop', C.VIDEO, A.TOGGLE,
M.NOTE_ON, 29, 'Toggle video looping'),
MidiActionMapping('event_video_seek', 'Video Seek', C.VIDEO, A.VARIABLE,
M.NOTE_ON, 31, 'Seek through video with velocity'),
MidiActionMapping('event_video_volume', 'Volume Level', C.VIDEO, A.VARIABLE,
M.NOTE_ON, 33, 'Adjust volume with velocity'),
# Group 3: General item actions
MidiActionMapping('event_item_previous', 'Item Previous ', C.ITEM, A.TRIGGER,
M.NOTE_ON, 36, 'Go to the previous item'),
MidiActionMapping('event_item_next', 'Item Next', C.ITEM, A.TRIGGER,
M.NOTE_ON, 38, 'Go to the next item'),
MidiActionMapping('event_item_goto', 'Item Go to Select', C.ITEM, A.VARIABLE,
M.NOTE_ON, 40, 'Go to a specific item with velocity'),
# Group 4: Slide/Song-specific actions
MidiActionMapping('event_slide_previous', 'Slide Previous', C.SLIDE, A.TRIGGER,
M.NOTE_ON, 48, 'Go to the previous slide'),
MidiActionMapping('event_slide_next', 'Slide Next', C.SLIDE, A.TRIGGER,
M.NOTE_ON, 50, 'Go to the next slide'),
MidiActionMapping('event_slide_goto', 'Slide Go to Select', C.SLIDE, A.VARIABLE,
M.NOTE_ON, 52, 'Go to a specific section with velocity'),
# Group 5: Song-specific transpose actions
MidiActionMapping('event_transpose_down', 'Transpose Down', C.TRANSPOSE, A.VARIABLE,
M.NOTE_ON, 60, 'Transpose the song down'),
MidiActionMapping('event_transpose_reset', 'Transpose Reset', C.TRANSPOSE, A.VARIABLE,
M.NOTE_ON, 61, 'Reset the song transposition'),
MidiActionMapping('event_transpose_up', 'Transpose Up', C.TRANSPOSE, A.VARIABLE,
M.NOTE_ON, 62, 'Transpose the song up'),
]
def get_default_action_midi_mappings() -> List[MidiActionMapping]:
"""
Returns a list of default MIDI action mappings.
Each item in the list is an instance of MidiActionMapping representing the default configuration for that action.
:return: A list of default MIDI action mappings.
"""
return default_action_midi_mappings
def get_default_action_midi_mappings_as_dict() -> dict[str, MidiActionMapping]:
"""
Returns a dictionary with the default MIDI action mappings.
Each key in the dictionary is a mapping key, and the corresponding value is an instance of MidiActionMapping
representing the default configuration for that action.
:return: A dictionary of default MIDI action mappings.
"""
mappings_dict = {mapping.mapping_key: mapping for mapping in default_action_midi_mappings}
return mappings_dict
# TODO: this class might not be needed
class DefaultMidiActionMappings:
"""
Class to hold and share default MIDI action mappings across the application.
"""
# Initialize the default action MIDI mappings as static variables
_default_action_mappings = get_default_action_midi_mappings()
_default_action_mappings_dict = get_default_action_midi_mappings_as_dict()
@staticmethod
def get_mappings() -> List[MidiActionMapping]:
"""
Returns a list of default MIDI action mappings.
:return: A list of default MIDI action mappings.
"""
return DefaultMidiActionMappings._default_action_mappings
@staticmethod
def get_mappings_dict() -> dict[str, MidiActionMapping]:
"""
Returns a dictionary with the default MIDI action mappings.
:return: A dictionary of default MIDI action mappings.
"""
return DefaultMidiActionMappings._default_action_mappings_dict
@staticmethod
def update_mapping(mapping_key: str, new_mapping: MidiActionMapping):
"""
Updates a specific MIDI action mapping.
:param mapping_key: The key of the mapping to update.
:param new_mapping: The new MidiActionMapping instance.
"""
if mapping_key in DefaultMidiActionMappings._default_action_mappings_dict:
DefaultMidiActionMappings._default_action_mappings_dict[mapping_key] = new_mapping
# Update the list as well
for i, mapping in enumerate(DefaultMidiActionMappings._default_action_mappings):
if mapping.mapping_key == mapping_key:
DefaultMidiActionMappings._default_action_mappings[i] = new_mapping
break

View File

@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
Simple conversion and type listing/enumeration of basic midi properties.
"""
from types import SimpleNamespace
# from enum import Enum # NOTE: would be used for the enum definitions
class MIDI_Def:
"""
MIDI_states provides utility functions and type definitions for MIDI message handling.
It includes methods for converting between MIDI notes and integers, and mappings
between different MIDI message representations.
"""
# MIDI C0 to int 0 mapping octave offset. Every octave shift adds +/-12 to the C0 int value
midi_map_octave_offset = -2
# List of musical notes
NOTES_LIST = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
# List of MIDI message types
MIDI_MESSAGE_TYPES_LIST = [
"... Disabled ...",
"Note On",
"Note Off",
"Control Change",
"Program Change",
"Pitch Bend Change",
# "Channel Pressure", # NOTE: this is disabled because it can't be used practically
# "Polyphonic Key Pressure", # NOTE: this is disabled because it can't be used practically
# "System Exclusive" # NOTE: this is disabled because it can't be used practically
]
# Namespace for MIDI message types
MESSAGE_NAMESPACE = SimpleNamespace(**{
msg.replace(" ", "_").upper(): SimpleNamespace(
ui_text_label=msg,
midi_type=msg.replace(" ", "_").lower()
) for msg in MIDI_MESSAGE_TYPES_LIST
})
# Enum for MIDI message types
# MESSAGE_ENUM = Enum('MIDIMessageType', {
# name: getattr(MESSAGE_NAMESPACE, name)
# for name in MESSAGE_NAMESPACE.__dict__
# })
@classmethod
def map_midi_msg_types_var_to_str_id(cls) -> SimpleNamespace:
"""
Maps variable names to MIDI type strings.
:return: SimpleNamespace mapping variable names to MIDI type strings.
"""
return SimpleNamespace(**{
name: getattr(cls.MESSAGE_NAMESPACE, name).midi_type
for name in cls.MESSAGE_NAMESPACE.__dict__
})
@classmethod
def map_ui_label_to_midi_type(cls) -> dict:
"""
Maps UI text labels to MIDI types.
:return: Dictionary mapping UI text labels to MIDI types.
"""
return {
getattr(cls.MESSAGE_NAMESPACE, name).ui_text_label:
getattr(cls.MESSAGE_NAMESPACE, name).midi_type
for name in cls.MESSAGE_NAMESPACE.__dict__
}
@classmethod
def map_midi_type_to_ui_label(cls) -> dict:
"""
Maps MIDI types to UI text labels.
:return: Dictionary mapping MIDI types to UI text labels.
"""
return {
getattr(cls.MESSAGE_NAMESPACE, name).midi_type:
getattr(cls.MESSAGE_NAMESPACE, name).ui_text_label
for name in cls.MESSAGE_NAMESPACE.__dict__
}
@classmethod
def create_notes_namespace(cls) -> SimpleNamespace:
"""
Creates a SimpleNamespace for notes.
:return: SimpleNamespace with note names.
"""
# Implies: SHARP => # => s
sharp = "s"
return SimpleNamespace(**{
note.replace("#", sharp).upper(): note for note in cls.NOTES_LIST
})
@staticmethod
def note_to_int(note: str) -> int:
"""
Converts a MIDI note to an integer.
:param note: MIDI note as a string.
:return: Integer value of the note.
:raises ValueError: If the note is invalid.
"""
note_base = note[:-1].replace('-', '')
octave = int(note[-1]) * (-1 if '-' in note else 1) - MIDI_Def.midi_map_octave_offset
if note_base in MIDI_Def.NOTES_LIST:
return MIDI_Def.NOTES_LIST.index(note_base) + (octave * 12)
else:
raise ValueError("Invalid note name")
@staticmethod
def int_to_note(note_number: int) -> str:
"""
Converts an integer to a MIDI note.
:param note_number: Integer value of the note.
:return: MIDI note as a string.
:raises ValueError: If the note number is out of the valid range.
"""
if 0 <= note_number <= 127:
note = MIDI_Def.NOTES_LIST[note_number % 12]
octave = (note_number // 12) + MIDI_Def.midi_map_octave_offset
if octave > 9:
raise ValueError("Note number results in an octave higher than 9")
return f"{note}{octave}"
else:
raise ValueError("Note number must be between 0 and 127")

View File

@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
"""
MIDI mapping definition that will provide consistent format between the various classes & methods
"""
import copy
from enum import Enum
class ActionType(Enum):
TRIGGER = 'Trigger'
TOGGLE = 'Toggle'
VARIABLE = 'Control' # TODO: decide on the best name for velocity/midi data value controlled
class ModuleCategory(Enum):
SCREEN = 'Screen'
VIDEO = 'Video'
ITEM = 'Item'
SLIDE = 'Slide'
TRANSPOSE = 'Transpose'
# TODO: we need some kind of input validation when the inputs are given. Right now only the managers are
# interracting with this class in a fixed manner, hance why extra validation is not compulsory
class MidiActionMapping:
"""
Represents a mapping of a UI action to a MIDI event.
:param mapping_key (str): A unique key identifying the mapping.
:param ui_action_label (str): Label for the UI action.
:param tx_action_type (str): Type of action on transmission (e.g., 'Toggle', 'Control').
:param category (str): Category of the action (e.g., 'Screen', 'Video').
:param midi_type (str): Type of MIDI event (e.g., 'On/Off', 'Continuous').
:param midi_data (int): MIDI data value associated with the event.
:param description (str, optional): Description of the action.
"""
def __init__(self, mapping_key: str, ui_action_label: str, category: ModuleCategory, tx_action_type: ActionType,
midi_type: str, midi_data: int, description: str = None, is_mappable_in_ui: bool = True):
self._mapping_key = mapping_key
self._ui_action_label = ui_action_label
self._tx_action_type = tx_action_type
self._category = category
self.midi_type = midi_type
self.midi_data = midi_data
self.description = description if description else ""
self._is_mappable_in_ui = is_mappable_in_ui
@property
def mapping_key(self) -> str:
"""Unique key identifying the mapping."""
return self._mapping_key
@property
def ui_action_label(self) -> str:
"""Label for the UI action."""
return self._ui_action_label
@property
def tx_action_type(self) -> ActionType:
"""Type of action (e.g., 'Toggle', 'Control')."""
return self._tx_action_type
@property
def category(self) -> ModuleCategory:
"""Category of the action (e.g., 'Screen', 'Video')."""
return self._category
@property
def is_mappable_in_ui(self) -> bool:
"""Boolean indicating if the event mapping is mappable/configurable in the UI."""
return self._is_mappable_in_ui
@tx_action_type.setter
def tx_action_type(self, new_action_type: ActionType): # TODO: check if this method will be used or not!
"""
Set a new transmission action type.
:param new_action_type: The new ActionType to set.
"""
if not isinstance(new_action_type, ActionType):
raise ValueError("Invalid action type. Must be an instance of ActionType.")
self._tx_action_type = new_action_type
def format_ui_action_label(self):
# NOTE: We will only add the transmission action type indicator
max_space = max(len(action.name) for action in ActionType.__members__.values())
space = (max_space - len(self._tx_action_type.value)) + 3 # Adds the extra space
total = 0 # TODO may be redundant
ui_label = f"[{self._tx_action_type.value}]{' ' * space}{self._ui_action_label}"
post_space = total - len(ui_label)
post_space = post_space if post_space > 0 else 0
return f"{ui_label}{' '*post_space}"
def update_midi_attributes(self, midi_type: str, midi_data: int) -> None:
"""
Updates the MIDI type and value attributes.
:param midi_type: The new MIDI type.
:param midi_data: The new MIDI data.
"""
self.midi_type = midi_type
self.midi_data = midi_data
def to_dict(self) -> dict:
"""
Converts the MIDI action mapping to a dictionary.
:return: A dictionary representation of the MIDI action mapping.
"""
return {
'mapping_key': self._mapping_key,
'label': self._ui_action_label,
'tx_action_type': self._tx_action_type,
'category': self._category,
'midi_type': self.midi_type,
'midi_data': self.midi_data,
'description': self.description
}
def update_from_orm_string(self, orm_string: str) -> None:
"""
Creates a MidiActionMapping instance from an ORM string.
:param orm_string: The ORM string to parse.
"""
parts = orm_string.split(':')
self.midi_type = str(parts[0])
self.midi_data = int(parts[1])
def update_from_ui_fields(self, midi_type: str, midi_data: int) -> None:
"""
Updates the MIDI type and value attributes from UI fields.
:param midi_type: The new MIDI type as a string.
:param midi_data: The new MIDI value as an integer.
"""
self.midi_type = str(midi_type)
self.midi_data = int(midi_data)
def export_to_orm_string(self) -> str:
"""
Converts the MIDI action mapping to a string format suitable for ORM storage.
:return: A string representation of the MIDI action mapping for ORM.
"""
return f"{self.midi_type}:{self.midi_data}"
def copy(self) -> 'MidiActionMapping':
"""
Creates a deep copy of this MidiActionMapping instance.
:return: A deep copy of the MidiActionMapping instance.
"""
return copy.deepcopy(self)

View File

@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2023 OpenLP Developers #
# ---------------------------------------------------------------------- #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import logging
from openlp.core.common.i18n import translate
from openlp.core.common.registry import Registry
from openlp.core.lib import build_icon
from openlp.core.lib.plugin import Plugin, StringContent
from openlp.core.state import State
from openlp.core.ui.icons import UiIcons
from openlp.core.common.enum import PluginStatus
from openlp.plugins.midi.forms.midisettingstab import MidiSettingsTab
from openlp.plugins.midi.lib.handlers_managers.midi_service import MidiControlService
logging.basicConfig(level=logging.DEBUG) # TODO: remove me!
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG) # TODO: remove me!
midi_settings = {
'midi/status': PluginStatus.Inactive,
'midi/db type': 'sqlite',
'midi/db username': '',
'midi/db password': '',
'midi/db hostname': '',
'midi/db database': ''
}
midi_icons = {
'midi': {'icon': 'mdi.midi'},
'midi_port': {'icon': 'mdi.midi-port'}
}
class MidiPlugin(Plugin):
def __init__(self):
"""
Class __init__ method to Initialize the MIDI plugin.
"""
log.info('MIDI Plugin loaded')
print("---------------------- midi ----------------------") # TODO: for testing/debugging. Remove later
super(MidiPlugin, self).__init__('midi', settings_tab_class=MidiSettingsTab)
# Basic Initializations
self.weight = -6
UiIcons().load_icons(midi_icons)
self.icon_path = UiIcons().midi_port
self.icon = build_icon(self.icon_path)
self.settings.extend_default_settings(midi_settings)
# State services
State().add_service(self.name, self.weight, is_plugin=True, requires='media_live')
# TODO: we need a provision for requires where requires states multiple modules
# Pre-conditions
State().update_pre_conditions(self.name, self.check_pre_conditions())
Registry().set_flag('midi_service_active', False) # Set to False so that it will wait
# Midi event service
self.midi_event_handler = MidiControlService(self)
def check_pre_conditions(self):
"""
Check the plugin can run.
For now, I'm returning True.
You may want to add more conditions as needed.
"""
live_controller_up = Registry().get('live_controller') is not None
service_manager_up = Registry().get('service_manager') is not None
media_controller_up = Registry().get('media_controller') is not None
media_controller_enabled = State().is_module_active('mediacontroller')
return live_controller_up and service_manager_up and media_controller_up and media_controller_enabled
def initialise(self):
"""
Initialise plugin
"""
log.info('Midi plugin Initialising')
Registry().set_flag('midi_service_active', True)
super(MidiPlugin, self).initialise()
self.midi_event_handler.start()
# Register midi control service update callbacks in the configuration panel
# Every time the plugin is activated the callbacks will be overwritten.
# This is ok, those that are the same will remain the same and new instances will overwrite the old callbacks.
# Register midi control service update callbacks in the configuration panel
MidiSettingsTab.add_listener_callback(listener_id="MidiService_close", callback_type="on_cfg_open",
callback=lambda event: self.midi_event_handler.close())
MidiSettingsTab.add_listener_callback(listener_id="MidiService_start", callback_type="on_cfg_close",
callback=lambda event: self.midi_event_handler.start())
log.info('MIDI Plugin successfully initialized.')
def finalise(self):
"""
Tidy up on exit
"""
log.info('Midi plugin Finalising')
super(MidiPlugin, self).finalise()
self.midi_event_handler.close()
Registry().set_flag('midi_service_active', False)
log.info('Midi plugin Finalized')
return
@staticmethod
def about():
about_text = translate('MidiPlugin', '<strong>MIDI Plugin</strong>'
'<br />The MIDI plugin provides the capability for duplex MIDI control '
'with OpenLP.')
return about_text
def set_plugin_text_strings(self):
"""
Called to define all translatable texts of the plugin.
"""
# Name PluginList
self.text_strings[StringContent.Name] = {
'singular': translate('MidiPlugin', 'MIDI Control', 'name singular'),
'plural': translate('MidiPlugin', 'MIDI Controls', 'name plural')
}
# Name for MediaDockManager, SettingsManager
self.text_strings[StringContent.VisibleName] = {'title': translate('MidiPlugin', 'MIDI', 'container title')}
# def exec_settings_dialog(self):
# """
# Display the MIDI settings dialog.
# """
# # if not self.midi_settings_dialog:
# # self.midi_settings_dialog = MidiSettingsDialog(self.main_window, self)
# # self.midi_settings_dialog.exec()
# pass

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff