mirror of https://gitlab.com/openlp/openlp.git
Create MIDI control plugin
This commit is contained in:
parent
6498b66698
commit
6fca42a753
|
@ -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.
|
|
@ -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
|
@ -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/>. #
|
||||
##########################################################################
|
|
@ -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/>. #
|
||||
##########################################################################
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -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/>. #
|
||||
##########################################################################
|
|
@ -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'
|
|
@ -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'
|
|
@ -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'
|
|
@ -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'
|
|
@ -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'
|
|
@ -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'
|
|
@ -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)
|
|
@ -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/>. #
|
||||
##########################################################################
|
|
@ -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
|
|
@ -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"
|
|
@ -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
|
|
@ -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")
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue