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.'), - ]