From 6fca42a753c5108c05bf92d2518abf90a1b14b48 Mon Sep 17 00:00:00 2001
From: JessyJP <49342341+JessyJP@users.noreply.github.com>
Date: Wed, 20 Dec 2023 20:02:24 +0000
Subject: [PATCH 1/3] Create MIDI control plugin
---
openlp/plugins/midi/__init__.py | 36 +
openlp/plugins/midi/forms/__init__.py | 44 +
openlp/plugins/midi/forms/midisettingstab.py | 1036 +++++++++++++++++
openlp/plugins/midi/lib/__init__.py | 20 +
.../midi/lib/handlers_managers/__init__.py | 20 +
.../lib/handlers_managers/device_handler.py | 226 ++++
.../lib/handlers_managers/midi_service.py | 186 +++
.../handlers_managers/profile_db_manager.py | 230 ++++
.../handlers_managers/state_event_manager.py | 392 +++++++
openlp/plugins/midi/lib/midi/__init__.py | 20 +
openlp/plugins/midi/lib/midi/blocking_mido.py | 184 +++
openlp/plugins/midi/lib/midi/listener.py | 33 +
.../midi/lib/midi/midi_listener_template.py | 193 +++
openlp/plugins/midi/lib/midi/mido.py | 79 ++
openlp/plugins/midi/lib/midi/pygame_midi.py | 120 ++
openlp/plugins/midi/lib/midi/python_rtmidi.py | 124 ++
openlp/plugins/midi/lib/midi/transmitter.py | 270 +++++
.../midi/lib/types_definitions/__init__.py | 20 +
.../types_definitions/config_profile_orm.py | 184 +++
.../midi/lib/types_definitions/constants.py | 40 +
.../define_midi_event_action_mapping.py | 157 +++
.../lib/types_definitions/midi_definitions.py | 151 +++
.../midi_event_action_map.py | 177 +++
openlp/plugins/midi/midiplugin.py | 154 +++
24 files changed, 4096 insertions(+)
create mode 100644 openlp/plugins/midi/__init__.py
create mode 100644 openlp/plugins/midi/forms/__init__.py
create mode 100644 openlp/plugins/midi/forms/midisettingstab.py
create mode 100644 openlp/plugins/midi/lib/__init__.py
create mode 100644 openlp/plugins/midi/lib/handlers_managers/__init__.py
create mode 100644 openlp/plugins/midi/lib/handlers_managers/device_handler.py
create mode 100644 openlp/plugins/midi/lib/handlers_managers/midi_service.py
create mode 100644 openlp/plugins/midi/lib/handlers_managers/profile_db_manager.py
create mode 100644 openlp/plugins/midi/lib/handlers_managers/state_event_manager.py
create mode 100644 openlp/plugins/midi/lib/midi/__init__.py
create mode 100644 openlp/plugins/midi/lib/midi/blocking_mido.py
create mode 100644 openlp/plugins/midi/lib/midi/listener.py
create mode 100644 openlp/plugins/midi/lib/midi/midi_listener_template.py
create mode 100644 openlp/plugins/midi/lib/midi/mido.py
create mode 100644 openlp/plugins/midi/lib/midi/pygame_midi.py
create mode 100644 openlp/plugins/midi/lib/midi/python_rtmidi.py
create mode 100644 openlp/plugins/midi/lib/midi/transmitter.py
create mode 100644 openlp/plugins/midi/lib/types_definitions/__init__.py
create mode 100644 openlp/plugins/midi/lib/types_definitions/config_profile_orm.py
create mode 100644 openlp/plugins/midi/lib/types_definitions/constants.py
create mode 100644 openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
create mode 100644 openlp/plugins/midi/lib/types_definitions/midi_definitions.py
create mode 100644 openlp/plugins/midi/lib/types_definitions/midi_event_action_map.py
create mode 100644 openlp/plugins/midi/midiplugin.py
diff --git a/openlp/plugins/midi/__init__.py b/openlp/plugins/midi/__init__.py
new file mode 100644
index 000000000..3a4e22ca6
--- /dev/null
+++ b/openlp/plugins/midi/__init__.py
@@ -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 . #
+##########################################################################
+
+"""
+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.
diff --git a/openlp/plugins/midi/forms/__init__.py b/openlp/plugins/midi/forms/__init__.py
new file mode 100644
index 000000000..2eb3600e6
--- /dev/null
+++ b/openlp/plugins/midi/forms/__init__.py
@@ -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 . #
+##########################################################################
+"""
+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_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 `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.
+"""
diff --git a/openlp/plugins/midi/forms/midisettingstab.py b/openlp/plugins/midi/forms/midisettingstab.py
new file mode 100644
index 000000000..24a37e1b3
--- /dev/null
+++ b/openlp/plugins/midi/forms/midisettingstab.py
@@ -0,0 +1,1036 @@
+# -*- 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 . #
+##########################################################################
+"""
+ This file contains the GUI for the configuration panel for the MIDI control plugin
+"""
+
+from types import SimpleNamespace
+# Qt Imports
+from PyQt5 import QtCore, QtWidgets
+from PyQt5.QtCore import QMetaObject, Qt
+# Settings tab
+from openlp.core.lib.settingstab import SettingsTab
+from openlp.core.common.i18n import translate
+# Icon imports
+from openlp.core.ui.icons import UiIcons
+from openlp.core.lib import build_icon
+# MIDI related imports
+from openlp.plugins.midi.lib.types_definitions.constants import openlp_midi_device, default_midi_device, \
+ assignment_message, disabled_midi_device
+from openlp.plugins.midi.lib.types_definitions.midi_definitions import MIDI_Def
+from openlp.plugins.midi.lib.midi.listener import MidiEventListener
+from openlp.plugins.midi.lib.handlers_managers.device_handler import MidiDeviceHandler
+from openlp.plugins.midi.lib.handlers_managers.profile_db_manager import MidiProfileManager, \
+ get_default_action_midi_mappings_as_dict
+
+ico_sp = " " # Define space between the text and the icon
+
+
+def add_icon_to(qt_ui_element, icon_label, position="left"):
+ ui_icons = UiIcons()
+ icon = None
+
+ # Check if UiIcons has the icon
+ if isinstance(icon_label, str) and hasattr(ui_icons, icon_label):
+ icon_path = getattr(ui_icons, icon_label)
+ icon = build_icon(icon_path)
+
+ # If icon is not built, try to build it from a direct path
+ if icon is None or icon.isNull():
+ try:
+ icon_dict = icon_label
+ icon_label = qt_ui_element.text()
+ for symbol in ['[', ']', ' ']:
+ icon_label = icon_label.replace(symbol, '').lower()
+ ui_icons.load_icons({icon_label: icon_dict})
+ icon_path = getattr(ui_icons, icon_label)
+ icon = build_icon(icon_path)
+ # icon = build_icon(icon_label) # TODO: we could potentially build it too
+ except Exception as e:
+ raise ValueError(f"Failed to build icon from UiIcons or direct path: {icon_label}") from e
+
+ # Check if the icon is still not valid
+ if icon.isNull():
+ raise ValueError(f"Invalid icon: {icon_label}")
+
+ # Set the icon on the button
+ pixel_size = 24
+ qt_ui_element.setIcon(icon)
+ qt_ui_element.setIconSize(QtCore.QSize(pixel_size, pixel_size))
+ if position == "right":
+ qt_type = qt_ui_element.__class__.__name__
+ qt_ui_element.setStyleSheet(qt_type + " { qproperty-iconSize: 24px; qproperty-layoutDirection: RightToLeft; }")
+
+
+event_to_icon_mapping = {
+ 'event_screen_show': 'live_presentation',
+ 'event_screen_theme': 'live_theme',
+ 'event_screen_blank': 'live_black',
+ 'event_screen_desktop': 'desktop',
+ 'event_clear_live': 'live_desktop', # 'delete'
+ 'event_video_play': 'play',
+ 'event_video_pause': 'pause',
+ 'event_video_stop': 'stop',
+ 'event_video_loop': 'loop',
+ 'event_video_seek': {'icon': 'mdi6.video-switch-outline'},
+ 'event_video_volume': 'music',
+ 'event_item_previous': 'arrow_left',
+ 'event_item_next': 'arrow_right',
+ 'event_item_goto': {'icon': 'mdi.cursor-pointer'},
+ 'event_slide_previous': 'move_up',
+ 'event_slide_next': 'move_down',
+ 'event_slide_goto': {'icon': 'mdi.select-place'},
+ 'event_transpose_up': {'icon': 'mdi6.music-note-plus'},
+ 'event_transpose_reset': {'icon': 'mdi6.music-note-quarter'},
+ 'event_transpose_down': {'icon': 'mdi6.music-note-minus'},
+ 'checkbox_play_is_toggle': {'icon': 'mdi.play-pause'}
+}
+
+
+class MidiSettingsTab(SettingsTab):
+ """
+ MidiSettingsTab is the MIDI settings tab in the settings dialog.
+ """
+ listener_callbacks = {
+ 'on_cfg_open': {},
+ 'on_cfg_close': {},
+ 'on_cfg_update': {},
+ }
+
+ def __init__(self, parent, title, visible_title, icon_path):
+ # The 'midi' identifier should probably be an argument, not hard coded. For now, leaving as is.
+ self._current_assignment_ = None
+ self._current_assignment_mel = None
+ self.profile_group = None
+ self.midi_devices_group = None
+ self.midi_actions_group = None
+ self.db_manager = None # Placeholder, instantiated in initialise()
+ self.previous_ui_action_values = {} # To store previous values for each action
+ super(MidiSettingsTab, self).__init__(parent, 'midi', visible_title, icon_path)
+
+ # ============== UI setup section ==============
+ def setup_ui(self):
+ """
+ Set up the tab's interface specific to Midi Settings.
+ """
+ self.setObjectName('MidiControlTab')
+ super().setup_ui()
+
+ # Group 1: Profile Management Group
+ self.profile_group = self._create_profile_group()
+
+ # Group 2: MIDI Devices and Channels Group
+ self.midi_devices_group = self._create_midi_devices_group()
+
+ # Group 3: MIDI Action Mapping Group
+ self.midi_actions_group = self._create_midi_actions_group()
+
+ # Set the layouts
+ self.left_layout.addWidget(self.profile_group)
+ self.left_layout.addWidget(self.midi_devices_group)
+ self.right_layout.addWidget(self.midi_actions_group)
+
+ def _create_profile_group(self):
+ """
+ Create and return the Profile Management group box.
+ """
+ # Initialize the profile_dropdown
+ self.profile_dropdown = QtWidgets.QComboBox()
+ # Connecting this method to the profile dropdown change signal
+ self.profile_dropdown.currentIndexChanged.connect(self._update_profile_callback)
+
+ # Create a QLabel for the "Profile:" label, spanning the entire row width and centered
+ self.profile_label = QtWidgets.QLabel("Profile:")
+ self.profile_label.setAlignment(Qt.AlignCenter)
+
+ # Initialize the profile management buttons
+ self.create_profile_button = QtWidgets.QPushButton("Create" + ico_sp)
+ self.create_profile_button.clicked.connect(self._create_profile_callback)
+ add_icon_to(self.create_profile_button, "new")
+
+ # Initialize the save button
+ self.save_profile_button = QtWidgets.QPushButton("Save" + ico_sp)
+ self.save_profile_button.clicked.connect(self._save_profile_callback)
+ add_icon_to(self.save_profile_button, "save")
+
+ self.rename_profile_button = QtWidgets.QPushButton("Rename" + ico_sp)
+ self.rename_profile_button.clicked.connect(self._rename_profile_callback)
+ add_icon_to(self.rename_profile_button, "edit")
+
+ self.delete_profile_button = QtWidgets.QPushButton("Delete" + ico_sp)
+ self.delete_profile_button.clicked.connect(self._delete_profile_callback)
+ add_icon_to(self.delete_profile_button, "delete")
+
+ # Create the profile group
+ profile_group = QtWidgets.QGroupBox("Profile Management", self)
+ profile_group_layout = QtWidgets.QGridLayout()
+
+ # Add the buttons to the profile management layout
+ self.profile_mgmt_layout = QtWidgets.QHBoxLayout()
+ self.profile_mgmt_layout.addWidget(self.create_profile_button)
+ self.profile_mgmt_layout.addWidget(self.save_profile_button)
+ self.profile_mgmt_layout.addWidget(self.rename_profile_button)
+ self.profile_mgmt_layout.addWidget(self.delete_profile_button)
+
+ # Merge the above layout into the main grid layout
+ # profile_group_layout.addWidget(self.profile_label, 0, 0)
+ profile_group_layout.addWidget(self.profile_dropdown, 0, 1)
+ profile_group_layout.addLayout(self.profile_mgmt_layout, 1, 1)
+
+ # Set vertical spacing for the layout
+ profile_group_layout.setVerticalSpacing(20)
+
+ profile_group.setLayout(profile_group_layout)
+ return profile_group
+
+ def _create_midi_devices_group(self):
+ """
+ Create and return the MIDI Devices and Channels group box.
+ """
+ col_span = 4 # Number of columns
+
+ # Initialize the input and output device dropdowns
+ self.midi_device_input_dropdown = QtWidgets.QComboBox()
+ self.midi_channel_input_dropdown = QtWidgets.QComboBox()
+
+ self.midi_device_output_dropdown = QtWidgets.QComboBox()
+ self.midi_channel_output_dropdown = QtWidgets.QComboBox()
+
+ # Create checkbox for MIDI device state reset when connected
+ self.reset_midi_state_checkbox = QtWidgets.QCheckBox("Reset MIDI state on connect")
+ self.reset_midi_state_checkbox.setChecked(False)
+
+ # Create checkbox for MIDI device state sync with OpenLP
+ self.device_sync_checkbox = QtWidgets.QCheckBox("Maintain MIDI device Sync")
+ self.device_sync_checkbox.setChecked(False)
+
+ self.play_button_is_toggle_checkbox = QtWidgets.QCheckBox("[Play] is a toggle state action")
+ self.play_button_is_toggle_checkbox.setChecked(False)
+ # TODO: why is it not displaying well
+ add_icon_to(self.play_button_is_toggle_checkbox, 'checkbox_play_is_toggle')
+
+ def _play_button_is_toggle_callback_(value):
+ text = self.midi_assignment_buttons['event_video_play'].text()
+ if value:
+ new_text = text.replace('[Trigger]', '[Toggle]')
+ else:
+ new_text = text.replace('[Toggle]', '[Trigger]')
+ self.midi_assignment_buttons['event_video_play'].setText(new_text)
+ self.retranslate_ui()
+ self.play_button_is_toggle_checkbox.stateChanged.connect(_play_button_is_toggle_callback_)
+
+ # Create checkbox for deferred_control_mode
+ self.deferred_control_mode_checkbox = QtWidgets.QCheckBox("Deferred Control Mode")
+ # Initially set the checkbox based on the default value
+ # (will be updated later based on actual value from the database)
+ self.deferred_control_mode_checkbox.setChecked(False)
+ # TODO: is disabled because implementation maybe done or not
+ self.deferred_control_mode_checkbox.setDisabled(True)
+
+ # Create a spinner for the control value modulation
+ self.control_offset_label = QtWidgets.QLabel('"Go-to" control offset')
+ self.control_offset_spinner = QtWidgets.QSpinBox()
+ self.control_offset_spinner.setRange(-127, 127)
+ self.control_offset_spinner.setValue(0)
+ # Add to a sub-layout
+ self.control_offset_layout = QtWidgets.QHBoxLayout() # Create a horizontal layout
+ self.control_offset_layout.addWidget(self.control_offset_label) # Add the label to the layout
+ self.control_offset_layout.addWidget(self.control_offset_spinner) # Add the spin box to the layout
+
+ self.input_devices_label = QtWidgets.QLabel("Input Devices:")
+ self.input_devices_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+
+ self.output_devices_label = QtWidgets.QLabel("Output Devices:")
+ self.output_devices_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+
+ # Create the MIDI devices group
+ midi_devices_group = QtWidgets.QGroupBox("MIDI Devices, Channels and Options", self)
+ midi_devices_layout = QtWidgets.QGridLayout()
+
+ visual_option = 2 # TODO: we have to commit to one of the arrangement options here
+ if visual_option == 1:
+ midi_devices_layout.addWidget(self.input_devices_label, 0, 0)
+ midi_devices_layout.addWidget(self.midi_device_input_dropdown, 0, 1)
+ midi_devices_layout.addWidget(self.midi_channel_input_dropdown, 0, 3)
+
+ midi_devices_layout.addWidget(self.output_devices_label, 1, 0)
+ midi_devices_layout.addWidget(self.midi_device_output_dropdown, 1, 1)
+ midi_devices_layout.addWidget(self.midi_channel_output_dropdown, 1, 3)
+
+ # Add the checkbox to the layout # Span the checkbox across all columns
+ midi_devices_layout.addWidget(self.reset_midi_state_checkbox, 2, 0, 1, col_span)
+ midi_devices_layout.addWidget(self.device_sync_checkbox, 3, 0, 1, col_span)
+ midi_devices_layout.addWidget(self.play_button_is_toggle_checkbox, 4, 0, 1, col_span)
+ # midi_devices_layout.addWidget(self.deferred_control_mode_checkbox, 2, 2, 1, col_span)
+ midi_devices_layout.addLayout(self.control_offset_layout, 5, 2, 1, col_span)
+
+ if visual_option == 2:
+ self.input_devices_label.setAlignment(Qt.AlignCenter | Qt.AlignTop)
+ self.output_devices_label.setAlignment(Qt.AlignCenter | Qt.AlignTop)
+
+ midi_devices_layout.addWidget(self.input_devices_label, 0, 0)
+ midi_devices_layout.addWidget(self.midi_device_input_dropdown, 1, 0)
+ midi_devices_layout.addWidget(self.midi_channel_input_dropdown, 1, 1)
+
+ midi_devices_layout.addWidget(self.output_devices_label, 2, 0)
+ midi_devices_layout.addWidget(self.midi_device_output_dropdown, 3, 0)
+ midi_devices_layout.addWidget(self.midi_channel_output_dropdown, 3, 1)
+
+ # Add the checkbox to the layout # Span the checkbox across all columns
+ midi_devices_layout.addWidget(self.reset_midi_state_checkbox, 4, 0)
+ midi_devices_layout.addWidget(self.device_sync_checkbox, 5, 0)
+ midi_devices_layout.addWidget(self.play_button_is_toggle_checkbox, 6, 0, 1, col_span)
+ # midi_devices_layout.addWidget(self.deferred_control_mode_checkbox, 2, 2, 1, col_span)
+ midi_devices_layout.addLayout(self.control_offset_layout, 7, 0)
+
+ # Set vertical spacing for the layout
+ midi_devices_layout.setVerticalSpacing(20)
+
+ # Add a stretch factor to the last row
+ # midi_devices_layout.setRowStretch(5, 1)
+
+ midi_devices_group.setLayout(midi_devices_layout)
+ return midi_devices_group
+
+ def _create_midi_actions_group(self):
+ """
+ Create and return the MIDI Action Mapping group box.
+ """
+ midi_actions_group = QtWidgets.QGroupBox("MIDI Event Action Mapping", self)
+ # -----------------------------------------------------------------------
+ _S_ = 1 # Scrollable rows
+ midi_actions_content = QtWidgets.QWidget()
+ # Create a widget and layout for the contents inside the scroll area
+ if _S_ == 0:
+ midi_actions_layout = QtWidgets.QGridLayout()
+ else:
+ midi_actions_layout = QtWidgets.QGridLayout(midi_actions_content)
+ # -----------------------------------------------------------------------
+
+ # Retrieve the dictionary with the default midi-event-action mappings
+ actions_dict = get_default_action_midi_mappings_as_dict()
+
+ self.midi_assignment_buttons = {}
+ self.midi_msg_data_spinboxes = {} # Using a dictionary to store QLineEdit references by action name
+ self.midi_msg_type_dropdown = {}
+ self.midi_data_note_label = {}
+
+ # NOTE: static default values are being assigned here.
+ # This can also be done at the beginning of the load() method.
+
+ for index, action in enumerate(actions_dict):
+ MAE = actions_dict[action] # Shorthand for MidiEventAction
+ if not MAE.is_mappable_in_ui:
+ continue
+
+ # Initialize previous values for each action
+ self.previous_ui_action_values[action] = {'midi_type': None, 'data_value': None}
+
+ # Create button for the dynamic assignment action
+ assignment_button = QtWidgets.QPushButton(MAE.format_ui_action_label())
+ assignment_button.setStyleSheet('text-align: left;') # Align text to the left
+ assignment_button.setToolTip(MAE.description)
+ assignment_button.clicked.connect(
+ lambda _, _a=action, _l=MAE.ui_action_label: self.handle_assignment_button_click(_a, _l))
+ self.midi_assignment_buttons[action] = assignment_button
+ add_icon_to(assignment_button, event_to_icon_mapping[action])
+ # -----------------------------------------------------------------------
+ # TODO: here we either use the label or the button. The "_S_" is an A/B switch to test both visualizations
+ # if _S_ == 0:
+ midi_actions_layout.addWidget(assignment_button, index, 0)
+ # else:
+ # midi_actions_layout.addWidget(assignment_button, (_S_ + 1) * index, 0, 1, 3)
+
+ # index = (_S_ + 1) * index + (1 if _S_ > 0 else 0)
+ # -----------------------------------------------------------------------
+ # Create spin box for MIDI value
+ midi_msg_data_spinbox = QtWidgets.QSpinBox()
+ midi_msg_data_spinbox.setRange(0, 127)
+ midi_msg_data_spinbox.setValue(MAE.midi_data)
+ midi_actions_layout.addWidget(midi_msg_data_spinbox, index, 1) # - _S_
+ # Storing reference using the original action name
+ self.midi_msg_data_spinboxes[action] = midi_msg_data_spinbox
+
+ # Create label for MIDI note
+ self.midi_data_note_label[action] = QtWidgets.QLabel(MIDI_Def.int_to_note(MAE.midi_data))
+ midi_actions_layout.addWidget(self.midi_data_note_label[action], index, 2) # - _S_
+
+ # Create dropdown for MIDI message type
+ midi_message_type_dropdown = QtWidgets.QComboBox()
+ midi_message_type_dropdown.addItems(MIDI_Def.MIDI_MESSAGE_TYPES_LIST)
+ midi_actions_layout.addWidget(midi_message_type_dropdown, index, 3) # - _S_
+ self.midi_msg_type_dropdown[action] = midi_message_type_dropdown
+ self.set_midi_message_type_dropdown(action, MAE.midi_type)
+
+ # Update MIDI note label when spin box value changes
+ midi_msg_data_spinbox.valueChanged.connect(
+ lambda value, _action=action: self.on_midi_data_ui_state_change_callback(_action)
+ )
+
+ midi_message_type_dropdown.currentIndexChanged.connect(
+ lambda value, _action=action: self.on_midi_data_ui_state_change_callback(_action)
+ )
+
+ # -----------------------------------------------------------------------
+ if _S_ == 0:
+ midi_actions_group.setLayout(midi_actions_layout) # TODO: original
+ else:
+ # Create a scroll area and set its widget to be the midi_actions_content
+ scroll_area = QtWidgets.QScrollArea()
+ scroll_area.setWidgetResizable(True) # Make the scroll area resizable
+ scroll_area.setWidget(midi_actions_content)
+
+ # Create a layout for the group box and add the scroll area to it
+ group_layout = QtWidgets.QVBoxLayout(midi_actions_group)
+ group_layout.addWidget(scroll_area)
+ # -----------------------------------------------------------------------
+
+ return midi_actions_group
+
+ def retranslate_ui(self):
+ """
+ Setup the interface translation strings for MidiSettingsTab.
+ """
+ # Using the translation function with the correct context
+ context = self.objectName()
+
+ # Translating group box titles
+ # TODO: that's a bit manual and hardcoded here
+ self.profile_group.setTitle(translate(context, "Profile Management"))
+ self.midi_devices_group.setTitle(translate(context, "MIDI Devices, Channels and Options"))
+ self.midi_actions_group.setTitle(translate(context, "MIDI Event Action Mapping"))
+
+ # Translating labels within the groups
+ self.profile_label.setText(translate(context, self.profile_label.text()))
+ self.input_devices_label.setText(translate(context, self.input_devices_label.text()))
+ self.output_devices_label.setText(translate(context, self.output_devices_label.text()))
+ self.reset_midi_state_checkbox.setText(translate(context, self.reset_midi_state_checkbox.text()))
+ self.device_sync_checkbox.setText(translate(context, self.device_sync_checkbox.text()))
+ self.play_button_is_toggle_checkbox.setText(translate(context, self.play_button_is_toggle_checkbox.text()))
+ self.deferred_control_mode_checkbox.setText(translate(context, self.deferred_control_mode_checkbox.text()))
+ self.control_offset_label.setText(translate(context, self.control_offset_label.text()))
+
+ # Translating buttons within the Profile Management group
+ self.create_profile_button.setText(translate(context, self.create_profile_button.text()))
+ self.save_profile_button.setText(translate(context, self.save_profile_button.text()))
+ self.rename_profile_button.setText(translate(context, self.rename_profile_button.text()))
+ self.delete_profile_button.setText(translate(context, self.delete_profile_button.text()))
+
+ # Translating labels within the MIDI Event Action Mapping group
+ for action, midi_data_field in self.midi_msg_data_spinboxes.items():
+ tal = translate(context, self.midi_assignment_buttons[action].text())
+ self.midi_assignment_buttons[action].setText(tal)
+
+ def initialise(self):
+ # Create the db manager before the constructor.
+ # The constructor will call the UI and will require the settings to be initialized.
+ self.db_manager = MidiProfileManager()
+
+ # ============== MIDI configuration tab state section ==============
+ # NOTE: this method overwrites the parent
+ def load(self):
+ """
+ Load settings and populate the profile dropdown and device dropdowns.
+ """
+ self.update_listeners("on_cfg_open")
+ # Load profiles and devices
+ self._load_midi_devices()
+ self._load_profile_names()
+ self._load_profile_state()
+ # NOTE: The order here matters as the devices and the profile names should be loaded first.
+ # When the profile state is reloaded it will determine how to handle the preselected profile
+ # device with the currently available devices.
+
+ # NOTE: this method overwrites the parent
+ def save(self):
+ self.ensure_mel_is_joined()
+ # self._save_profile_callback() # TODO: consider if you want to save the current profile on clicking "OK"
+ # TODO: maybe there should be a dialogue asking if the changes should be saved.
+ # TODO: That means we have to have a way to extract state and compare it to change if there is something to save
+ MidiSettingsTab.update_listeners(callback_type="on_cfg_close")
+ pass
+
+ # NOTE: this method overwrites the parent
+ def cancel(self):
+ self.ensure_mel_is_joined()
+ MidiSettingsTab.update_listeners(callback_type="on_cfg_close")
+
+ # ============== MIDI configuration loading section ==============
+
+ def _load_midi_devices(self):
+ """
+ Load MIDI devices into the input and output device dropdowns.
+ Load MIDI channels into the input and output channel dropdowns.
+ """
+ # Populating the midi devices dropdowns
+ midi_input_devices = MidiDeviceHandler.get_input_midi_devices()
+ midi_output_devices = MidiDeviceHandler.get_output_midi_devices()
+
+ # Add placeholder options
+ midi_input_devices.insert(0, disabled_midi_device['input'])
+ midi_input_devices.insert(1, openlp_midi_device['gui_label'])
+ midi_output_devices.insert(0, disabled_midi_device['output'])
+ # TODO: we may want to do the same for the output device and set OpenLP as output device
+ # TODO : put those text labels in the constants file
+
+ self.midi_device_input_dropdown.clear()
+ self.midi_device_output_dropdown.clear()
+
+ self.midi_device_input_dropdown.addItems(midi_input_devices)
+ self.midi_device_output_dropdown.addItems(midi_output_devices)
+
+ # Populating the midi channel dropdowns
+ midi_channels = MidiDeviceHandler.get_midi_channels_list()
+
+ # Clear the existing items in the dropdowns
+ self.midi_channel_input_dropdown.clear()
+ self.midi_channel_output_dropdown.clear()
+
+ # Add the MIDI channels to the dropdowns
+ self.midi_channel_input_dropdown.addItems(midi_channels)
+ self.midi_channel_output_dropdown.addItems(midi_channels)
+
+ # Select the last entry in the MIDI channel dropdowns
+ _in_ch_ind = MidiDeviceHandler.get_channel_index(default_midi_device['output_channel'])
+ self.midi_channel_input_dropdown.setCurrentIndex(_in_ch_ind)
+ _out_ch_ind = MidiDeviceHandler.get_channel_index(default_midi_device['input_channel'])
+ self.midi_channel_output_dropdown.setCurrentIndex(_out_ch_ind)
+
+ def _load_profile_names(self):
+ """
+ Load profile names into the profile dropdown.
+ """
+ profile_names = self.db_manager.get_all_profiles()
+ selected_profile_name = None
+
+ # Loop to find the selected profile
+ for profile in profile_names:
+ if self.db_manager.get_property(profile, 'is_selected_profile'):
+ selected_profile_name = profile
+ break
+
+ self.profile_dropdown.clear()
+ # We need to temporarily disconnect the callback because we are adding and item,
+ # and we don't want unwanted callbacks to get triggered
+ self.profile_dropdown.blockSignals(True)
+ self.profile_dropdown.addItems(profile_names)
+ self.profile_dropdown.blockSignals(False)
+
+ # Set the selected profile if found
+ if selected_profile_name:
+ index = self.profile_dropdown.findText(selected_profile_name)
+ if index != -1:
+ self.profile_dropdown.setCurrentIndex(index)
+
+ self._prevent_rename_or_delete_of_default_profile()
+
+ def _load_profile_state(self):
+ """
+ Load the profile state based on the currently selected profile.
+ This function acts as a callback for the profile dropdown change event.
+ """
+ currently_selected_profile = self.profile_dropdown.currentText()
+ if not currently_selected_profile and currently_selected_profile not in self.db_manager.get_all_profiles():
+ return
+
+ self.db_manager.set_profile_as_currently_selected(currently_selected_profile)
+ # Fetch the profile state from the db_manager.
+ # I'm assuming a method `get_profile_state` exists in the db_manager.
+ # This method should return a dictionary with keys corresponding to the settings.
+ profile_state = self.db_manager.get_profile_state(currently_selected_profile)
+
+ # Set the MIDI input and output devices and channels from the loaded state.
+ # If not present in the profile state, keep the default (i.e., do not change).
+ if "input_midi_device" in profile_state:
+ index = self.midi_device_input_dropdown.findText(profile_state["input_midi_device"])
+ if index != -1:
+ self.midi_device_input_dropdown.setCurrentIndex(index)
+ else:
+ self.midi_device_input_dropdown.setCurrentIndex(0)
+
+ if "input_device_channel" in profile_state:
+ index = self.midi_channel_input_dropdown.findText(str(profile_state["input_device_channel"]))
+ if index != -1:
+ self.midi_channel_input_dropdown.setCurrentIndex(index)
+
+ if "output_midi_device" in profile_state:
+ index = self.midi_device_output_dropdown.findText(profile_state["output_midi_device"])
+ if index != -1:
+ self.midi_device_output_dropdown.setCurrentIndex(index)
+ else:
+ self.midi_device_output_dropdown.setCurrentIndex(0)
+
+ if "output_device_channel" in profile_state:
+ index = self.midi_channel_output_dropdown.findText(str(profile_state["output_device_channel"]))
+ if index != -1:
+ self.midi_channel_output_dropdown.setCurrentIndex(index)
+
+ # Set the MIDI state reset checkbox based on the loaded profile.
+ if "reset_midi_state" in profile_state:
+ self.reset_midi_state_checkbox.setChecked(profile_state["reset_midi_state"])
+
+ if "device_sync" in profile_state:
+ self.device_sync_checkbox.setChecked(profile_state["device_sync"])
+
+ if "play_button_is_toggle" in profile_state:
+ self.play_button_is_toggle_checkbox.setChecked(profile_state["play_button_is_toggle"])
+
+ # Set the deferred control mode checkbox based on the loaded profile.
+ if "deferred_control_mode" in profile_state:
+ self.deferred_control_mode_checkbox.setChecked(profile_state["deferred_control_mode"])
+
+ if "control_offset" in profile_state:
+ self.control_offset_spinner.setValue(profile_state["control_offset"])
+
+ # Load action mappings
+ for action in self.midi_msg_data_spinboxes.keys():
+ if action in profile_state:
+ # NOTE: We block the signals because we don't want to trigger the duplication check during assignment
+ self.midi_msg_type_dropdown[action].blockSignals(True)
+ self.midi_msg_data_spinboxes[action].blockSignals(True)
+ self.set_midi_message_type_dropdown(action, profile_state[action].midi_type)
+ self.set_midi_data_value(action, profile_state[action].midi_data)
+ self.midi_msg_type_dropdown[action].blockSignals(False)
+ self.midi_msg_data_spinboxes[action].blockSignals(False)
+ # After all values are updated we can manually trigger the callback once to make sure the UI updated
+ self.on_midi_data_ui_state_change_callback(action)
+
+ # ============== MIDI configuration get from UI state section ==============
+ def get_ui_configuration_state(self) -> dict:
+ """
+ Extracts the current state of all mappings from the UI settings tab.
+
+ Returns:
+ dict: A dictionary containing the current UI settings.
+ """
+ ui_settings = dict()
+
+ # Get the currently selected profile
+ ui_settings["profile"] = self.profile_dropdown.currentText()
+
+ # Get the currently selected MIDI input and output devices and channels
+ ui_settings["input_midi_device"] = self.midi_device_input_dropdown.currentText()
+ ui_settings["input_device_channel"] = self.midi_channel_input_dropdown.currentText()
+ ui_settings["output_midi_device"] = self.midi_device_output_dropdown.currentText()
+ ui_settings["output_device_channel"] = self.midi_channel_output_dropdown.currentText()
+
+ # Get the state of the deferred control mode checkbox
+ ui_settings["reset_midi_state"] = self.reset_midi_state_checkbox.isChecked()
+ ui_settings["device_sync"] = self.device_sync_checkbox.isChecked()
+ ui_settings["play_button_is_toggle"] = self.play_button_is_toggle_checkbox.isChecked()
+ ui_settings["deferred_control_mode"] = self.deferred_control_mode_checkbox.isChecked()
+ ui_settings["control_offset"] = int(self.control_offset_spinner.value())
+
+ # Loop through the midi value spin boxes to get the current mapping values
+ action_mappings = {}
+ for action, midi_data_field in self.midi_msg_data_spinboxes.items():
+ # NOTE: Here we simply export the modifiable fields as dictionary.
+ # TODO: alternatively "self.midi_msg_data_spinboxes[action]"
+ action_mappings[action] = {
+ 'midi_data': midi_data_field.text(),
+ 'midi_type': self.midi_msg_type_dropdown[action].currentText()
+ }
+
+ # Merge the 2 dictionaries together and return the dictionary
+ configuration_state = {**ui_settings, **action_mappings}
+ return configuration_state
+
+ # ============== Profile management handlers section ==============
+
+ def _create_profile_callback(self):
+ """
+ Slot to handle the creation of a new profile.
+ """
+ profile_name, ok = QtWidgets.QInputDialog.getText(self, 'Create Profile', 'Enter profile name:')
+ if ok and profile_name:
+ try:
+ # Logic to create profile
+ self.db_manager.create_profile(profile_name)
+
+ # Add the profile name at the bottom and select it in the dropdown
+ # We need to temporarily disconnect the callback because we are adding and item,
+ # and we don't want unwanted callbacks to get triggered
+ self.profile_dropdown.blockSignals(True)
+ self.profile_dropdown.addItem(profile_name)
+ # TODO: there is some weirdness related to creating a profile.
+ # it's not behaving as expected and is creating default profile.
+ # Set the selected profile if found
+ index = self.profile_dropdown.findText(profile_name)
+ if index != -1:
+ self.profile_dropdown.setCurrentIndex(index)
+ self.profile_dropdown.blockSignals(False)
+
+ self._save_profile_callback()
+ self._prevent_rename_or_delete_of_default_profile()
+ except Exception as e:
+ # Display the error message
+ error_dialog = QtWidgets.QMessageBox(self)
+ error_dialog.setIcon(QtWidgets.QMessageBox.Critical)
+ error_dialog.setWindowTitle("Error Creating Profile")
+ error_dialog.setText("An error occurred while creating the profile.")
+ error_dialog.setInformativeText(str(e))
+ error_dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ error_dialog.exec_()
+
+ def _save_profile_callback(self):
+ """
+ Save the current UI settings to the database.
+ """
+ # 1. Get the current UI settings using the get_action_event_mapping_from_ui method
+ config_state = self.get_ui_configuration_state()
+
+ # 2. Check if the dictionary keys from the UI match the database column names (consistency check)
+ action_keys = self.db_manager.get_midi_event_action_key_list()
+ all_db_properties = (self.db_manager.get_midi_config_properties_key_list() + action_keys)
+
+ # Check if all keys in ui_settings match with database columns (consistency check)
+ for key in config_state.keys():
+ if key not in all_db_properties:
+ raise ValueError(f"UI setting '{key}' does not match any database column.")
+
+ # 3. Save the current UI settings to the relevant database fields
+ currently_selected_profile = config_state["profile"]
+
+ # If no profile is selected, create a new profile called default and select it
+ if currently_selected_profile == '':
+ currently_selected_profile = "default"
+ self.db_manager.create_profile("default")
+ self._load_profile_names()
+ config_state["profile"] = currently_selected_profile
+
+ self.db_manager.set_profile_as_currently_selected(currently_selected_profile)
+
+ for property_name, value in config_state.items():
+ # NOTE: We are not using a template or copy of the default midi-action-event mapping, therefore,
+ # for consistency, we use a SimpleNamespace form the dictionary. This way we ensure that
+ # the set method treats the input the same, regardless of whether the modified fields passed
+ # like this or with a midi-event mapping instance.
+ if property_name in action_keys:
+ value = SimpleNamespace(**value)
+ self.db_manager.set_property(currently_selected_profile, property_name, value)
+
+ # Done! The settings from the UI have been saved to the database.
+
+ def _rename_profile_callback(self):
+ """
+ Slot to handle renaming an existing profile.
+ """
+ current_name = self.profile_dropdown.currentText()
+ new_name, ok = QtWidgets.QInputDialog.getText(self, 'Rename Profile', 'Enter new profile name:',
+ text=current_name)
+ if ok and new_name:
+ try:
+ # Logic to rename profile
+ self.db_manager.rename_profile(current_name, new_name)
+ self.db_manager.set_profile_as_currently_selected(new_name)
+ self._load_midi_devices()
+ self._load_profile_names()
+ self._load_profile_state()
+ except Exception as e:
+ # Display the error message
+ error_dialog = QtWidgets.QMessageBox(self)
+ error_dialog.setIcon(QtWidgets.QMessageBox.Critical)
+ error_dialog.setWindowTitle("Error Renaming Profile")
+ error_dialog.setText("An error occurred while renaming the profile.")
+ error_dialog.setInformativeText(str(e))
+ error_dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ error_dialog.exec_()
+
+ def _delete_profile_callback(self):
+ """
+ Slot to handle deletion of a profile.
+ """
+ profile_name = self.profile_dropdown.currentText()
+ confirm = QtWidgets.QMessageBox.question(self, 'Delete Profile',
+ f'Are you sure you want to delete the profile "{profile_name}"?')
+ if confirm == QtWidgets.QMessageBox.Yes:
+ try:
+ # Logic to delete profile
+ self.db_manager.delete_profile(profile_name)
+ self._load_midi_devices()
+ self._load_profile_names()
+ self._load_profile_state()
+ except Exception as e:
+ # Display the error message
+ error_dialog = QtWidgets.QMessageBox(self)
+ error_dialog.setIcon(QtWidgets.QMessageBox.Critical)
+ error_dialog.setWindowTitle("Error Deleting Profile")
+ error_dialog.setText("An error occurred while deleting the profile.")
+ error_dialog.setInformativeText(str(e))
+ error_dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ error_dialog.exec_()
+
+ def _update_profile_callback(self):
+ self._load_profile_state()
+ self._prevent_rename_or_delete_of_default_profile()
+ # TODO : this is effective the reset call
+ MidiSettingsTab.update_listeners(callback_type="on_cfg_update")
+
+ def _prevent_rename_or_delete_of_default_profile(self):
+ # Prevent the default profile from being renamed or deleted
+ isEnabled = True
+ if self.profile_dropdown.currentText() == "default":
+ isEnabled = False
+ self.rename_profile_button.setEnabled(isEnabled)
+ self.delete_profile_button.setEnabled(isEnabled)
+
+ # ============== MIDI assignment handlers section ==============
+
+ def handle_assignment_button_click(self, action, ui_action_label):
+
+ if self._current_assignment_ is None: # Activate assignment
+ # NOTE: Only the main thread can enter here. Ensure the assignment MIDI event listener thread was joined
+ self.ensure_mel_is_joined()
+
+ # Start a listener thread to get single MIDI event
+ self._current_assignment_mel = MidiEventListener(on_receive_callback_ref=self.handle_midi_data_assignment)
+ # Try to connect to the current device and return if unsuccessful
+ device_name = self.midi_device_input_dropdown.currentText()
+ device_name = MidiDeviceHandler.match_exact_input_device_name(device_name)
+ if not self._current_assignment_mel.connect_to_device(device_name):
+ return
+ # Start the listening tread
+ self._current_assignment_mel.get_single_midi_event_thread_start()
+
+ # Store the current assignment configuration
+ self._current_assignment_ = {'action': action,
+ 'original_label': self.midi_assignment_buttons[action].text(),
+ 'original_style': self.midi_assignment_buttons[action].styleSheet(),
+ 'ui_action_label': ui_action_label}
+ self._current_assignment_ = SimpleNamespace(**self._current_assignment_)
+
+ # Set temporary properties for label and style
+ self.midi_assignment_buttons[action].setText(assignment_message)
+ self.midi_assignment_buttons[action].setStyleSheet("background-color: red;")
+ print(f"Dynamic midi assignment!\n"
+ f"Waiting for the first MIDI input to be assigned to [{ui_action_label}]!\n"
+ "Please press the midi button you want to assign!")
+
+ else: # Deactivate assignment
+ action = self._current_assignment_.action
+ # Revert to the original label and style properties
+ self.midi_assignment_buttons[action].setText(self._current_assignment_.original_label)
+ self.midi_assignment_buttons[action].setStyleSheet(self._current_assignment_.original_style)
+ # Get the MIDI event listener & close any threads
+ self._current_assignment_mel.stop_from_inside()
+ self._current_assignment_ = None
+ print("Dynamic midi assignment disabled!")
+
+ def handle_midi_data_assignment(self, midi_message):
+ action = self._current_assignment_.action
+ label = self._current_assignment_.ui_action_label
+ text_info = f"Dynamic MIDI assignment message detected [{midi_message}] which will be assigned to[{label}]!\n"
+ print(text_info)
+
+ if midi_message is not None:
+ duplicate_found, duplicate_action = self.check_for_mapping_duplication_from_message(action, midi_message)
+ if not duplicate_found:
+ midi_data = self._current_assignment_mel.extract_midi_data1(midi_message)
+
+ # Assign the MIDI event to the UI elements
+ self.set_midi_message_type_dropdown(action, midi_message.type)
+ self.set_midi_data_value(action, midi_data)
+ else:
+ actions_dict = get_default_action_midi_mappings_as_dict()
+ duplicate_action_label = actions_dict[duplicate_action].ui_action_label
+ # self.show_duplicate_mapping_message(duplicate_action_label)
+ # Use QMetaObject.invokeMethod to call the method in the main thread
+ QMetaObject.invokeMethod(self, "show_duplicate_mapping_message", Qt.QueuedConnection,
+ QtCore.Q_ARG(str, duplicate_action_label))
+
+ # Stop the listening thread and reset the UI
+ self.handle_assignment_button_click("", "")
+
+ def ensure_mel_is_joined(self):
+ # NOTE: This can't be initialized from inside the thread.
+ # There fore we can call that separately on the main thread on UI state change relevant to this listener.
+ if self._current_assignment_mel is not None:
+ thread = self._current_assignment_mel.thread
+ if thread and thread.is_alive():
+ thread.join()
+
+ # ============== MIDI assignment duplication/validation section ==============
+
+ def check_for_mapping_duplication_from_message(self, event_action, midi_message):
+ message_type = midi_message.type
+ midi_data = self._current_assignment_mel.extract_midi_data1(midi_message)
+ return self.check_for_mapping_duplication(event_action, message_type, midi_data)
+
+ def check_for_mapping_duplication(self, event_action, message_type, midi_data):
+ duplicate_found = False
+ duplicate_action = None
+ # Ensure the argument states are normalized
+ message_type = message_type.replace(' ', '_').lower() # TODO: again the ugly conversion
+ midi_data = int(midi_data)
+
+ # Get the current UI configuration state
+ mappings = self.get_ui_configuration_state()
+
+ # Filter only event mappings
+ event_mappings = {event: mapping for event, mapping in mappings.items() if event.startswith("event_")}
+
+ # Iterate through existing mappings to check for duplication
+ for action, mapping in event_mappings.items():
+ # If the action is overwritten with the same value then we just skip
+ if action == event_action: # Avoid self-conflict check
+ continue
+ # Ensure the argument states are normalized
+ mapping['midi_type'] = mapping['midi_type'].replace(' ', '_').lower() # TODO: again the ugly conversion
+ mapping['midi_data'] = int(mapping['midi_data'])
+ if mapping['midi_type'] == message_type and mapping['midi_data'] == midi_data:
+ print(f"Duplicate mapping found for action: {action}")
+ duplicate_found = True
+ duplicate_action = action
+ break
+
+ return duplicate_found, duplicate_action
+
+ @QtCore.pyqtSlot(str)
+ def show_duplicate_mapping_message(self, duplicate_action_label):
+ # This method will run in the main thread
+ # Show message box for duplication
+ msg_box = QtWidgets.QMessageBox()
+ msg_box.setWindowTitle("Duplicate MIDI Mapping Detected!")
+ msg_box.setText(f"Duplicate MIDI mapping found for action: [ {duplicate_action_label} ]!")
+ msg_box.setInformativeText("Please assign a different MIDI control or note.")
+ msg_box.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg_box.exec_() # Display the message box
+
+ # ============== Set MIDI type and value section ==============
+
+ def set_midi_message_type_dropdown(self, action, message_type):
+ """
+ Set the MIDI message type for a specific action in the dropdown.
+ Ignore capitalization, spaces, and underscores.
+ """
+
+ # TODO: generally we should be using mapping rather that normalization, but it's kinda easy ... for now
+ # mapping = MIDI_states.map_midi_type_to_ui_label()
+ # ui_label = mapping[message_type]
+ def normalize(text):
+ return text.lower().replace(" ", "").replace("_", "")
+
+ normalized_message_type = normalize(message_type)
+
+ if action in self.midi_msg_type_dropdown:
+ dropdown = self.midi_msg_type_dropdown[action]
+ for i in range(dropdown.count()):
+ if normalize(dropdown.itemText(i)) == normalized_message_type:
+ dropdown.setCurrentIndex(i)
+ self.previous_ui_action_values[action]['midi_type'] = dropdown.itemText(i)
+ break
+
+ def set_midi_data_value(self, action, data_value):
+ """
+ Set the MIDI data value for a specific action. The value can be a number or a note representation.
+ """
+ if action in self.midi_msg_data_spinboxes:
+ spin_box = self.midi_msg_data_spinboxes[action]
+
+ if isinstance(data_value, str):
+ data_value = int(data_value)
+ spin_box.setValue(data_value)
+ self.previous_ui_action_values[action]['data_value'] = data_value
+
+ def on_midi_data_ui_state_change_callback(self, action):
+ """
+ Callback for when MIDI UI elements are changed manually.
+
+ Args:
+ action (str): The action associated with the UI elements.
+ """
+ duplicate_found, duplicate_action = self.check_for_mapping_duplication(
+ event_action=action,
+ message_type=self.midi_msg_type_dropdown[action].currentText(),
+ midi_data=int(self.midi_msg_data_spinboxes[action].value())
+ )
+ if duplicate_found and duplicate_action != action: # Avoid self-conflict check
+ actions_dict = get_default_action_midi_mappings_as_dict()
+ # Notify user of duplication and revert changes or handle as needed
+ self.show_duplicate_mapping_message(actions_dict[duplicate_action].ui_action_label)
+ # Revert to previous state
+ self.set_midi_message_type_dropdown(action, self.previous_ui_action_values[action]['midi_type'])
+ self.set_midi_data_value(action, self.previous_ui_action_values[action]['data_value'])
+
+ # Get the normalized midi type string
+ midi_type = self.midi_msg_type_dropdown[action].currentText()
+ spin_box = self.midi_msg_data_spinboxes[action]
+
+ # Determine the midi type data 1 & normalize the midi data
+ data_value = spin_box.value()
+ norm_midi_type = midi_type.lower() # .replace(" ", "").replace("_", "")
+
+ # Save the current state
+ self.previous_ui_action_values[action]['midi_type'] = midi_type
+ self.previous_ui_action_values[action]['data_value'] = data_value
+
+ # Handle the text label
+ text = ""
+ if "note" in norm_midi_type:
+ text = MIDI_Def.int_to_note(data_value)
+ if "control" in norm_midi_type:
+ text = "CC"
+ self.midi_data_note_label[action].setText(text)
+
+ # Handle the spin box state
+ if not ("note" in norm_midi_type or "control" in norm_midi_type):
+ spin_box.setDisabled(True)
+ spin_box.setVisible(False)
+ else:
+ spin_box.setDisabled(False)
+ spin_box.setVisible(True)
+
+ # ============== Listeners management section ==============
+
+ @staticmethod
+ def add_listener_callback(listener_id: str, callback_type: str, callback: classmethod):
+ """
+ Registers a callback method for a given listener.
+
+ This method allows external components to subscribe to updates or changes
+ in the MIDI settings. When the MIDI settings are updated, these subscribed
+ listeners will be notified.
+
+ Args:
+ listener_id (str): A unique identifier for the listener.
+ callback_type (str): The type of callback (e.g., 'on_open', 'on_close').
+ callback (classmethod): The callback method to be called.
+ """
+ if callback_type not in MidiSettingsTab.listener_callbacks:
+ raise ValueError(f"Unknown callback type: {callback_type}")
+ MidiSettingsTab.listener_callbacks[callback_type][listener_id] = callback
+
+ @staticmethod
+ def update_listeners(callback_type: str, event=None):
+ """
+ Notifies all registered listeners about a change in the MIDI settings.
+
+ This method iterates through all the registered listeners and invokes their
+ callback methods. This is typically called after a change or update in the
+ MIDI settings to inform all listeners about the change.
+ """
+ event = None # TODO: pause here
+ if callback_type not in MidiSettingsTab.listener_callbacks:
+ raise ValueError(f"Unknown callback type: {callback_type}")
+
+ for listener_id, callback in MidiSettingsTab.listener_callbacks[callback_type].items():
+ print(f"Update {listener_id} about midi settings change.")
+ callback(event) # Pass the event to the callback
diff --git a/openlp/plugins/midi/lib/__init__.py b/openlp/plugins/midi/lib/__init__.py
new file mode 100644
index 000000000..d3bb15e5d
--- /dev/null
+++ b/openlp/plugins/midi/lib/__init__.py
@@ -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 . #
+##########################################################################
diff --git a/openlp/plugins/midi/lib/handlers_managers/__init__.py b/openlp/plugins/midi/lib/handlers_managers/__init__.py
new file mode 100644
index 000000000..d3bb15e5d
--- /dev/null
+++ b/openlp/plugins/midi/lib/handlers_managers/__init__.py
@@ -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 . #
+##########################################################################
diff --git a/openlp/plugins/midi/lib/handlers_managers/device_handler.py b/openlp/plugins/midi/lib/handlers_managers/device_handler.py
new file mode 100644
index 000000000..e7f9e267a
--- /dev/null
+++ b/openlp/plugins/midi/lib/handlers_managers/device_handler.py
@@ -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 . #
+##########################################################################
+
+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
diff --git a/openlp/plugins/midi/lib/handlers_managers/midi_service.py b/openlp/plugins/midi/lib/handlers_managers/midi_service.py
new file mode 100644
index 000000000..89932d192
--- /dev/null
+++ b/openlp/plugins/midi/lib/handlers_managers/midi_service.py
@@ -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 . #
+##########################################################################
+
+"""
+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()
diff --git a/openlp/plugins/midi/lib/handlers_managers/profile_db_manager.py b/openlp/plugins/midi/lib/handlers_managers/profile_db_manager.py
new file mode 100644
index 000000000..9ccdffa74
--- /dev/null
+++ b/openlp/plugins/midi/lib/handlers_managers/profile_db_manager.py
@@ -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 . #
+##########################################################################
+"""
+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
diff --git a/openlp/plugins/midi/lib/handlers_managers/state_event_manager.py b/openlp/plugins/midi/lib/handlers_managers/state_event_manager.py
new file mode 100644
index 000000000..cce850e3a
--- /dev/null
+++ b/openlp/plugins/midi/lib/handlers_managers/state_event_manager.py
@@ -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 . #
+##########################################################################
+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
diff --git a/openlp/plugins/midi/lib/midi/__init__.py b/openlp/plugins/midi/lib/midi/__init__.py
new file mode 100644
index 000000000..d3bb15e5d
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/__init__.py
@@ -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 . #
+##########################################################################
diff --git a/openlp/plugins/midi/lib/midi/blocking_mido.py b/openlp/plugins/midi/lib/midi/blocking_mido.py
new file mode 100644
index 000000000..2695a9821
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/blocking_mido.py
@@ -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 . #
+##########################################################################
+
+"""
+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'
diff --git a/openlp/plugins/midi/lib/midi/listener.py b/openlp/plugins/midi/lib/midi/listener.py
new file mode 100644
index 000000000..64b5b36b0
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/listener.py
@@ -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'
diff --git a/openlp/plugins/midi/lib/midi/midi_listener_template.py b/openlp/plugins/midi/lib/midi/midi_listener_template.py
new file mode 100644
index 000000000..c31c8655f
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/midi_listener_template.py
@@ -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'
diff --git a/openlp/plugins/midi/lib/midi/mido.py b/openlp/plugins/midi/lib/midi/mido.py
new file mode 100644
index 000000000..cef71e93a
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/mido.py
@@ -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'
diff --git a/openlp/plugins/midi/lib/midi/pygame_midi.py b/openlp/plugins/midi/lib/midi/pygame_midi.py
new file mode 100644
index 000000000..ed341ff7c
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/pygame_midi.py
@@ -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'
diff --git a/openlp/plugins/midi/lib/midi/python_rtmidi.py b/openlp/plugins/midi/lib/midi/python_rtmidi.py
new file mode 100644
index 000000000..950db47be
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/python_rtmidi.py
@@ -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'
diff --git a/openlp/plugins/midi/lib/midi/transmitter.py b/openlp/plugins/midi/lib/midi/transmitter.py
new file mode 100644
index 000000000..a2db3e162
--- /dev/null
+++ b/openlp/plugins/midi/lib/midi/transmitter.py
@@ -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 . #
+##########################################################################
+
+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)
diff --git a/openlp/plugins/midi/lib/types_definitions/__init__.py b/openlp/plugins/midi/lib/types_definitions/__init__.py
new file mode 100644
index 000000000..d3bb15e5d
--- /dev/null
+++ b/openlp/plugins/midi/lib/types_definitions/__init__.py
@@ -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 . #
+##########################################################################
diff --git a/openlp/plugins/midi/lib/types_definitions/config_profile_orm.py b/openlp/plugins/midi/lib/types_definitions/config_profile_orm.py
new file mode 100644
index 000000000..9983e42da
--- /dev/null
+++ b/openlp/plugins/midi/lib/types_definitions/config_profile_orm.py
@@ -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 . #
+##########################################################################
+"""
+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
diff --git a/openlp/plugins/midi/lib/types_definitions/constants.py b/openlp/plugins/midi/lib/types_definitions/constants.py
new file mode 100644
index 000000000..b2d5ccfaa
--- /dev/null
+++ b/openlp/plugins/midi/lib/types_definitions/constants.py
@@ -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 . #
+##########################################################################
+
+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"
diff --git a/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py b/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
new file mode 100644
index 000000000..77380dc90
--- /dev/null
+++ b/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
@@ -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 . #
+##########################################################################
+
+"""
+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
diff --git a/openlp/plugins/midi/lib/types_definitions/midi_definitions.py b/openlp/plugins/midi/lib/types_definitions/midi_definitions.py
new file mode 100644
index 000000000..32982d45c
--- /dev/null
+++ b/openlp/plugins/midi/lib/types_definitions/midi_definitions.py
@@ -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 . #
+##########################################################################
+"""
+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")
diff --git a/openlp/plugins/midi/lib/types_definitions/midi_event_action_map.py b/openlp/plugins/midi/lib/types_definitions/midi_event_action_map.py
new file mode 100644
index 000000000..1815e7b64
--- /dev/null
+++ b/openlp/plugins/midi/lib/types_definitions/midi_event_action_map.py
@@ -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 . #
+##########################################################################
+"""
+ 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)
diff --git a/openlp/plugins/midi/midiplugin.py b/openlp/plugins/midi/midiplugin.py
new file mode 100644
index 000000000..5e4d1efa4
--- /dev/null
+++ b/openlp/plugins/midi/midiplugin.py
@@ -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 . #
+##########################################################################
+
+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', 'MIDI Plugin'
+ '
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
From f6f6c0291ff066f82cb94267f6c38f13f025d10f Mon Sep 17 00:00:00 2001
From: JessyJP <49342341+JessyJP@users.noreply.github.com>
Date: Fri, 19 Jan 2024 00:41:14 +0000
Subject: [PATCH 2/3] Update define_midi_event_action_mapping.py
---
.../define_midi_event_action_mapping.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py b/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
index 77380dc90..e3b416ff9 100644
--- a/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
+++ b/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
@@ -76,6 +76,11 @@ default_action_midi_mappings = [
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'),
+ MidiActionMapping('event_timer_start', 'Slide Timer Start', C.SLIDE, A.TOGGLE,
+ M.NOTE_ON, 54, 'Start/Stop the slider timer.'),
+ MidiActionMapping('event_timer_set', 'Slide Timer Set', C.SLIDE, A.VARIABLE,
+ M.NOTE_ON, 55, 'Set the slider timer value in seconds.'),
+ # TODO: maybe put the timer in a separate group
# Group 5: Song-specific transpose actions
MidiActionMapping('event_transpose_down', 'Transpose Down', C.TRANSPOSE, A.VARIABLE,
@@ -84,6 +89,14 @@ default_action_midi_mappings = [
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'),
+
+ # Group 6:Bible display selection actions
+ # TODO: maybe put the timer in a separate group
+ MidiActionMapping('event_bible_book', 'Bible Go To Book', C.SLIDE, A.VARIABLE,
+ M.NOTE_ON, 56, 'Set to a selected Bible book.'),
+ MidiActionMapping('event_bible_chapter', 'Bible Go To Chapter', C.SLIDE, A.VARIABLE,
+ M.NOTE_ON, 57, 'Set to a selected Bible chapter.'),
+
]
From f05d64be0703495002967b67432cbf65a1f66b08 Mon Sep 17 00:00:00 2001
From: JessyJP <49342341+JessyJP@users.noreply.github.com>
Date: Fri, 19 Jan 2024 01:58:30 +0000
Subject: [PATCH 3/3] Revert "Update define_midi_event_action_mapping.py"
This reverts commit 1dd2e265ba54b59d567130573816e5bc843ccb34.
---
.../define_midi_event_action_mapping.py | 13 -------------
1 file changed, 13 deletions(-)
diff --git a/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py b/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
index e3b416ff9..77380dc90 100644
--- a/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
+++ b/openlp/plugins/midi/lib/types_definitions/define_midi_event_action_mapping.py
@@ -76,11 +76,6 @@ default_action_midi_mappings = [
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'),
- MidiActionMapping('event_timer_start', 'Slide Timer Start', C.SLIDE, A.TOGGLE,
- M.NOTE_ON, 54, 'Start/Stop the slider timer.'),
- MidiActionMapping('event_timer_set', 'Slide Timer Set', C.SLIDE, A.VARIABLE,
- M.NOTE_ON, 55, 'Set the slider timer value in seconds.'),
- # TODO: maybe put the timer in a separate group
# Group 5: Song-specific transpose actions
MidiActionMapping('event_transpose_down', 'Transpose Down', C.TRANSPOSE, A.VARIABLE,
@@ -89,14 +84,6 @@ default_action_midi_mappings = [
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'),
-
- # Group 6:Bible display selection actions
- # TODO: maybe put the timer in a separate group
- MidiActionMapping('event_bible_book', 'Bible Go To Book', C.SLIDE, A.VARIABLE,
- M.NOTE_ON, 56, 'Set to a selected Bible book.'),
- MidiActionMapping('event_bible_chapter', 'Bible Go To Chapter', C.SLIDE, A.VARIABLE,
- M.NOTE_ON, 57, 'Set to a selected Bible chapter.'),
-
]