diff --git a/lisp/core/signal.py b/lisp/core/signal.py index 1b38188e9..216cd4306 100644 --- a/lisp/core/signal.py +++ b/lisp/core/signal.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2016 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -217,6 +217,9 @@ def emit(self, *args, **kwargs): except Exception: traceback.print_exc() + def is_connected_to(self, slot_callable): + return slot_id(slot_callable) in self.__slots + def __remove_slot(self, id_): with self.__lock: self.__slots.pop(id_, None) diff --git a/lisp/plugins/controller/protocols/midi.py b/lisp/plugins/controller/protocols/midi.py index e5cb280df..e33e39cf5 100644 --- a/lisp/plugins/controller/protocols/midi.py +++ b/lisp/plugins/controller/protocols/midi.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2016 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -35,16 +35,6 @@ from lisp.core.plugin import PluginNotLoadedError from lisp.plugins.controller.common import LayoutAction, tr_layout_action from lisp.plugins.controller.protocol import Protocol -from lisp.plugins.midi.midi_utils import ( - MIDI_MSGS_NAME, - midi_data_from_msg, - midi_msg_from_data, - midi_from_dict, - midi_from_str, - MIDI_MSGS_SPEC, - MIDI_ATTRS_SPEC, -) -from lisp.plugins.midi.widgets import MIDIMessageEditDialog from lisp.ui.qdelegates import ( CueActionDelegate, EnumComboBoxDelegate, @@ -54,6 +44,21 @@ from lisp.ui.settings.pages import CuePageMixin, SettingsPage from lisp.ui.ui_utils import translate +try: + from lisp.plugins.midi.midi_utils import ( + MIDI_MSGS_NAME, + midi_data_from_msg, + midi_msg_from_data, + midi_from_dict, + midi_from_str, + MIDI_MSGS_SPEC, + MIDI_ATTRS_SPEC, + PortDirection, + ) + from lisp.plugins.midi.widgets import MIDIPatchCombo, MIDIMessageEditDialog +except ImportError: + midi_from_str = lambda *_: None + logger = logging.getLogger(__name__) @@ -75,6 +80,16 @@ def __init__(self, actionDelegate, **kwargs): self.midiModel = MidiModel() + try: + self.__midi = get_plugin("Midi") + except PluginNotLoadedError: + self.setEnabled(False) + self.midiNotInstalledMessage = QLabel() + self.midiNotInstalledMessage.setAlignment(Qt.AlignCenter) + self.midiGroup.layout().addWidget(self.midiNotInstalledMessage) + self.retranslateUi() + return + self.midiView = MidiView(actionDelegate, parent=self.midiGroup) self.midiView.setModel(self.midiModel) self.midiGroup.layout().addWidget(self.midiView, 0, 0, 1, 2) @@ -98,6 +113,9 @@ def __init__(self, actionDelegate, **kwargs): self.filterLabel.setAlignment(Qt.AlignCenter) self.filterLayout.addWidget(self.filterLabel) + self.filterPatchCombo = MIDIPatchCombo(PortDirection.Input, self.midiGroup) + self.filterLayout.addWidget(self.filterPatchCombo) + self.filterTypeCombo = QComboBox(self.midiGroup) self.filterLayout.addWidget(self.filterTypeCombo) @@ -113,12 +131,13 @@ def __init__(self, actionDelegate, **kwargs): self.retranslateUi() self._defaultAction = None - try: - self.__midi = get_plugin("Midi") - except PluginNotLoadedError: - self.setEnabled(False) def retranslateUi(self): + if hasattr(self, "midiNotInstalledMessage"): + self.midiNotInstalledMessage.setText( + translate("ControllerSettings", "MIDI plugin not installed")) + return + self.addButton.setText(translate("ControllerSettings", "Add")) self.removeButton.setText(translate("ControllerSettings", "Remove")) @@ -126,6 +145,7 @@ def retranslateUi(self): self.filterLabel.setText( translate("ControllerMidiSettings", "Capture filter") ) + self.filterPatchCombo.retranslateUi() def enableCheck(self, enabled): self.setGroupEnabled(self.midiGroup, enabled) @@ -133,15 +153,21 @@ def enableCheck(self, enabled): def getSettings(self): entries = [] for row in range(self.midiModel.rowCount()): + patch_id = self.midiModel.getPatchId(row) message, action = self.midiModel.getMessage(row) - entries.append((str(message), action)) + entries.append((f"{patch_id} {str(message)}", action)) return {"midi": entries} def loadSettings(self, settings): for entry in settings.get("midi", ()): try: - self.midiModel.appendMessage(midi_from_str(entry[0]), entry[1]) + entry_split = entry[0].split(" ", 1) + if '#' not in entry_split[0]: + # Backwards compatibility for config without patches + self.midiModel.appendMessage("in#1", midi_from_str(entry[0]), entry[1]) + else: + self.midiModel.appendMessage(entry_split[0], midi_from_str(entry_split[1]), entry[1]) except Exception: logger.warning( translate( @@ -152,35 +178,35 @@ def loadSettings(self, settings): ) def capture_message(self): - handler = self.__midi.input - handler.alternate_mode = True - handler.new_message_alt.connect(self.__add_message) - - QMessageBox.information( - self, - "", - translate("ControllerMidiSettings", "Listening MIDI messages ..."), - ) - - handler.new_message_alt.disconnect(self.__add_message) - handler.alternate_mode = False + settings = [ + self.filterPatchCombo.currentData(), + self.__add_message + ] + if self.__midi.add_exclusive_callback(*settings): + QMessageBox.information( + self, + "", + translate("ControllerMidiSettings", "Listening MIDI messages ..."), + ) + self.__midi.remove_exclusive_callback(*settings) - def __add_message(self, message): + def __add_message(self, patch_id, message): mgs_filter = self.filterTypeCombo.currentData(Qt.UserRole) if mgs_filter == self.FILTER_ALL or message.type == mgs_filter: if hasattr(message, "velocity"): message = message.copy(velocity=0) - self.midiModel.appendMessage(message, self._defaultAction) + self.midiModel.appendMessage(patch_id, message, self._defaultAction) def __new_message(self): - dialog = MIDIMessageEditDialog() + dialog = MIDIMessageEditDialog(PortDirection.Input) if dialog.exec() == MIDIMessageEditDialog.Accepted: message = midi_from_dict(dialog.getMessageDict()) + patch_id = dialog.getPatchId() if hasattr(message, "velocity"): message.velocity = 0 - self.midiModel.appendMessage(message, self._defaultAction) + self.midiModel.appendMessage(patch_id, message, self._defaultAction) def __remove_message(self): self.midiModel.removeRow(self.midiView.currentIndex().row()) @@ -226,11 +252,11 @@ def _text(self, option, index): value = index.data() if value is not None: model = index.model() - message_type = model.data(model.index(index.row(), 0)) + message_type = model.data(model.index(index.row(), 1)) message_spec = MIDI_MSGS_SPEC.get(message_type, ()) - if len(message_spec) >= index.column(): - attr = message_spec[index.column() - 1] + if len(message_spec) >= index.column() - 1: + attr = message_spec[index.column() - 2] attr_spec = MIDI_ATTRS_SPEC.get(attr) if attr_spec is not None: @@ -243,6 +269,7 @@ class MidiModel(SimpleTableModel): def __init__(self): super().__init__( [ + translate("ControllerMidiSettings", "MIDI Patch"), translate("ControllerMidiSettings", "Type"), translate("ControllerMidiSettings", "Data 1"), translate("ControllerMidiSettings", "Data 2"), @@ -250,36 +277,55 @@ def __init__(self): translate("ControllerMidiSettings", "Action"), ] ) + try: + self.__midi = get_plugin("Midi") + if not self.__midi.is_loaded(): + self.__midi = None + except PluginNotLoadedError: + self.__midi = None - def appendMessage(self, message, action): + def appendMessage(self, patch_id, message, action): + if not self.__midi: + return data = midi_data_from_msg(message) data.extend((None,) * (3 - len(data))) - self.appendRow(message.type, *data, action) + self.appendRow(patch_id, message.type, *data, action) - def updateMessage(self, row, message, action): + def updateMessage(self, row, patch_id, message, action): data = midi_data_from_msg(message) data.extend((None,) * (3 - len(data))) - self.updateRow(row, message.type, *data, action) + self.updateRow(row, patch_id, message.type, *data, action) def getMessage(self, row): if row < len(self.rows): return ( - midi_msg_from_data(self.rows[row][0], self.rows[row][1:4]), - self.rows[row][4], + midi_msg_from_data(self.rows[row][1], self.rows[row][2:5]), + self.rows[row][5], ) + def getPatchId(self, row): + if row < len(self.rows): + return self.rows[row][0] + def flags(self, index): - if index.column() <= 3: + if index.column() <= 4: return Qt.ItemIsEnabled | Qt.ItemIsSelectable else: return super().flags(index) + def data(self, index, role=Qt.DisplayRole): + if index.isValid() and index.column() == 0 and role == Qt.DisplayRole: + return f"{self.__midi.input_name_formatted(self.getPatchId(index.row()))[:16]}..." + + return super().data(index, role) + class MidiView(QTableView): def __init__(self, actionDelegate, **kwargs): super().__init__(**kwargs) self.delegates = [ + LabelDelegate(), MidiMessageTypeDelegate(), MidiValueDelegate(), MidiValueDelegate(), @@ -310,15 +356,17 @@ def __init__(self, actionDelegate, **kwargs): self.doubleClicked.connect(self.__doubleClicked) def __doubleClicked(self, index): - if index.column() <= 3: + if index.column() <= 4: + patch_id = self.model().getPatchId(index.row()) message, action = self.model().getMessage(index.row()) - dialog = MIDIMessageEditDialog() + dialog = MIDIMessageEditDialog(PortDirection.Input) + dialog.setPatchId(patch_id) dialog.setMessageDict(message.dict()) if dialog.exec() == MIDIMessageEditDialog.Accepted: self.model().updateMessage( - index.row(), midi_from_dict(dialog.getMessageDict()), action + index.row(), dialog.getPatchId(), midi_from_dict(dialog.getMessageDict()), action ) @@ -328,11 +376,16 @@ class Midi(Protocol): def __init__(self): super().__init__() - # Install callback for new MIDI messages - get_plugin("Midi").input.new_message.connect(self.__new_message) + try: + # Install callback for new MIDI messages + midi = get_plugin("Midi") + if midi.is_loaded(): + midi.received.connect(self.__new_message) + except PluginNotLoadedError: + pass - def __new_message(self, message): + def __new_message(self, patch_id, message): if hasattr(message, "velocity"): message = message.copy(velocity=0) - self.protocol_event.emit(str(message)) + self.protocol_event.emit(f"{patch_id} {str(message)}") diff --git a/lisp/plugins/midi/default.json b/lisp/plugins/midi/default.json index d74bf8296..cf71a37c0 100644 --- a/lisp/plugins/midi/default.json +++ b/lisp/plugins/midi/default.json @@ -2,7 +2,7 @@ "_version_": "2.1", "_enabled_": true, "backend": "mido.backends.rtmidi", - "inputDevice": "", - "outputDevice": "", + "inputDevices": {}, + "outputDevices": {}, "connectByNameMatch": true -} \ No newline at end of file +} diff --git a/lisp/plugins/midi/midi.py b/lisp/plugins/midi/midi.py index 1838f752a..0416939d4 100644 --- a/lisp/plugins/midi/midi.py +++ b/lisp/plugins/midi/midi.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2021 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,11 +21,11 @@ from PyQt5.QtCore import QT_TRANSLATE_NOOP from lisp.core.plugin import Plugin -from lisp.core.signal import Connection +from lisp.core.signal import Connection, Signal from lisp.plugins.midi.midi_cue import MidiCue from lisp.plugins.midi.midi_io import MIDIOutput, MIDIInput, MIDIBase from lisp.plugins.midi.midi_settings import MIDISettings -from lisp.plugins.midi.midi_utils import midi_output_names, midi_input_names +from lisp.plugins.midi.midi_utils import format_patch_name, midi_output_names, midi_input_names, PortDirection, PortNameMatch, PortStatus from lisp.plugins.midi.port_monitor import ALSAPortMonitor from lisp.ui.settings.app_configuration import AppConfigurationDialog from lisp.ui.ui_utils import translate @@ -64,15 +64,16 @@ def __init__(self, app): self.__default_input = avail_inputs[0] if avail_inputs else "" self.__default_output = avail_outputs[0] if avail_outputs else "" - # Create input handler and connect - current_input = self.input_name() - self.__input = MIDIInput(self.backend, current_input) - self._reconnect(self.__input, current_input, avail_inputs) + # Create input handlers and connect + self.received = Signal() + self.__inputs = {} + for patch_id, device_name in self.input_patches().items(): + self._connect(patch_id, device_name, PortDirection.Input) - # Create output handler and connect - current_output = self.output_name() - self.__output = MIDIOutput(self.backend, current_output) - self._reconnect(self.__output, current_output, avail_outputs) + # Create output handlers and connect + self.__outputs = {} + for patch_id, device_name in self.output_patches().items(): + self._connect(patch_id, device_name, PortDirection.Output) # Monitor ports, for auto-reconnection. # Since current midi backends are not reliable on @@ -89,47 +90,143 @@ def __init__(self, app): Midi.Config.changed.connect(self.__config_change) Midi.Config.updated.connect(self.__config_update) - @property - def input(self): - return self.__input + def add_exclusive_callback(self, patch_id, callback): + if patch_id not in self.__inputs: + return False + handler = self.__inputs[patch_id] + if handler.exclusive_mode: + return False + handler.exclusive_mode = True + handler.received_exclusive.connect(callback) + return True - @property - def output(self): - return self.__output + def remove_exclusive_callback(self, patch_id, callback): + if patch_id not in self.__inputs: + return + handler = self.__inputs[patch_id] + if handler.received_exclusive.is_connected_to(callback): + handler.exclusive_mode = False + handler.received_exclusive.disconnect(callback) - def input_name(self): - return Midi.Config["inputDevice"] or self.__default_input + def input_name(self, patch_id): + return self.__inputs[patch_id].port_name() - def output_name(self): - return Midi.Config["outputDevice"] or self.__default_output + def input_name_formatted(self, patch_id): + return format_patch_name(patch_id, self.__inputs[patch_id].port_name()) + + def input_name_match(self, patch_id, candidate_name): + if patch_id not in self.__inputs: + return PortNameMatch.NoMatch + port_name = self.__inputs[patch_id].port_name() + if candidate_name == port_name: + return PortNameMatch.ExactMatch + if self.Config['connectByNameMatch'] and self._port_search_match(candidate_name, [port_name]): + return PortNameMatch.FuzzyMatch + return PortNameMatch.NoMatch + + def input_patches(self): + patches = {} + for k, v in Midi.Config.get("inputDevices", {}).items(): + if v is not None: + patches[k] = v + if not patches and Midi.Config.get("inputDevice", None) is not None: + patches = { f"{PortDirection.Input.value}#1": Midi.Config.get("inputDevice", self.__default_input) } + return patches + + def input_status(self, patch_id): + if patch_id not in self.__inputs: + return PortStatus.DoesNotExist + return PortStatus.Open if self.__inputs[patch_id].is_open() else PortStatus.Closed + + def output_name(self, patch_id): + return self.__outputs[patch_id].port_name() + + def output_name_formatted(self, patch_id): + return format_patch_name(patch_id, self.__outputs[patch_id].port_name()) + + def output_name_match(self, patch_id, candidate_name): + if patch_id not in self.__outputs: + return PortNameMatch.NoMatch + port_name = self.__outputs[patch_id].port_name() + if candidate_name == port_name: + return PortNameMatch.ExactMatch + if self.Config['connectByNameMatch'] and self._port_search_match(candidate_name, [port_name]): + return PortNameMatch.FuzzyMatch + return PortNameMatch.NoMatch + + def output_patches(self): + patches = {} + for k, v in Midi.Config.get("outputDevices", {}).items(): + if v is not None: + patches[k] = v + if not patches and Midi.Config.get("outputDevice", None) is not None: + patches = { f"{PortDirection.Output.value}#1": Midi.Config.get("outputDevice", self.__default_output) } + return patches + + def output_status(self, patch_id): + if patch_id not in self.__outputs: + return PortStatus.DoesNotExist + return PortStatus.Open if self.__outputs[patch_id].is_open() else PortStatus.Closed + + def output_patch_exists(self, patch_id): + return patch_id in self.__outputs + + def send(self, patch_id, message): + self.__outputs[patch_id].send(message) def _on_port_removed(self): - if self.__input.is_open(): - if self.input_name() not in midi_input_names(): + avail_names = self.backend.get_input_names() + for port in self.__inputs.values(): + if port.is_open() and port.port_name() not in avail_names: logger.info( translate( "MIDIInfo", "MIDI port disconnected: '{}'" - ).format(self.__input.port_name()) + ).format(port.port_name()) ) - self.__input.close() + port.close() - if self.__output.is_open(): - if self.output_name() not in midi_output_names(): + avail_names = self.backend.get_output_names() + for port in self.__outputs.values(): + if port.is_open() and port.port_name() not in avail_names: logger.info( translate( "MIDIInfo", "MIDI port disconnected: '{}'" - ).format(self.__output.port_name()) + ).format(port.port_name()) ) - self.__input.close() + port.close() def _on_port_added(self): - if not self.__input.is_open(): - self._reconnect(self.__input, self.input_name(), midi_input_names()) + avail_names = self.backend.get_input_names() + for port in self.__inputs.values(): + if not port.is_open() and port.port_name() in avail_names: + self._reconnect(port, port.port_name(), avail_names) - if not self.__output.is_open(): - self._reconnect( - self.__output, self.output_name(), midi_output_names() - ) + avail_names = self.backend.get_output_names() + for port in self.__outputs.values(): + if not port.is_open() and port.port_name() in avail_names: + self._reconnect(port, port.port_name(), avail_names) + + def _dispatch_message(self, patch_id, message): + self.received.emit(patch_id, message) + + def _connect(self, patch_id, device_name, direction: PortDirection): + if direction is PortDirection.Input: + available = self.backend.get_input_names() + self.__inputs[patch_id] = MIDIInput(self.backend, patch_id, device_name) + self.__inputs[patch_id].received.connect(self._dispatch_message) + self._reconnect(self.__inputs[patch_id], device_name, available) + elif direction is PortDirection.Output: + available = self.backend.get_output_names() + self.__outputs[patch_id] = MIDIOutput(self.backend, patch_id, device_name) + self._reconnect(self.__outputs[patch_id], device_name, available) + + def _disconnect(self, patch_id, direction: PortDirection): + if direction is PortDirection.Input: + self.__inputs[patch_id].close() + del self.__inputs[patch_id] + elif direction is PortDirection.Output: + self.__outputs[patch_id].close() + del self.__outputs[patch_id] def _reconnect(self, midi: MIDIBase, current: str, available: list): if current in available: @@ -157,14 +254,47 @@ def _port_search_match(self, to_match, available_names): if possible_match.startswith(simple_name): return possible_match - def __config_change(self, key, _): - if key == "inputDevice": - self.__input.change_port(self.input_name()) - elif key == "outputDevice": - self.__output.change_port(self.output_name()) + def __config_change(self, key, changeset): + if key == "inputDevices": + available = self.backend.get_input_names() + for patch_id, device_name in changeset.items(): + if patch_id not in self.__inputs: + self._connect(patch_id, device_name, PortDirection.Input) + elif device_name is None: + self._disconnect(patch_id, PortDirection.Input) + elif device_name == "": + self.__inputs[patch_id].change_port(self.__default_input) + elif device_name in available: + self.__inputs[patch_id].change_port(device_name) + else: + if Midi.Config["connectByNameMatch"]: + match = self._port_search_match(device_name, available) + if match is not None: + self.__inputs[patch_id].change_port(match) + return + self.__inputs[patch_id].change_port(device_name, False) + + elif key == "outputDevices": + available = self.backend.get_output_names() + for patch_id, device_name in changeset.items(): + if patch_id not in self.__outputs: + self._connect(patch_id, device_name, PortDirection.Output) + elif device_name is None: + self._disconnect(patch_id, PortDirection.Output) + elif device_name == "": + self.__outputs[patch_id].change_port(self.__default_output) + elif device_name in available: + self.__outputs[patch_id].change_port(device_name) + else: + if Midi.Config["connectByNameMatch"]: + match = self._port_search_match(device_name, available) + if match is not None: + self.__outputs[patch_id].change_port(match) + return + self.__outputs[patch_id].change_port(device_name, False) def __config_update(self, diff): - if "inputDevice" in diff: - self.__config_change("inputDevice", diff["inputDevice"]) - if "outputDevice" in diff: - self.__config_change("outputDevice", diff["outputDevice"]) + if "inputDevices" in diff: + self.__config_change("inputDevices", diff["inputDevices"]) + if "outputDevices" in diff: + self.__config_change("outputDevices", diff["outputDevices"]) diff --git a/lisp/plugins/midi/midi_cue.py b/lisp/plugins/midi/midi_cue.py index 502fb20dc..fdc698129 100644 --- a/lisp/plugins/midi/midi_cue.py +++ b/lisp/plugins/midi/midi_cue.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2018 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -27,6 +27,7 @@ midi_str_to_dict, midi_dict_to_str, midi_from_str, + PortDirection, ) from lisp.plugins.midi.widgets import MIDIMessageEdit from lisp.ui.settings.cue_settings import CueSettingsRegistry @@ -39,6 +40,7 @@ class MidiCue(Cue): Name = QT_TRANSLATE_NOOP("CueName", "MIDI Cue") + patch_id = Property(default="") message = Property(default="") def __init__(self, *args, **kwargs): @@ -47,11 +49,16 @@ def __init__(self, *args, **kwargs): self.__midi = get_plugin("Midi") def __start__(self, fade=False): - if self.message: - self.__midi.output.send(midi_from_str(self.message)) + if self.message and self.patch_id: + self.__midi.send(self.patch_id, midi_from_str(self.message)) return False + def update_properties(self, properties): + super().update_properties(properties) + if not self.__midi.output_patch_exists(properties.get("patch_id", "out#1")): + self._error() + class MidiCueSettings(SettingsPage): Name = QT_TRANSLATE_NOOP("SettingsPageName", "MIDI Settings") @@ -61,7 +68,7 @@ def __init__(self, **kwargs): self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) - self.midiEdit = MIDIMessageEdit(parent=self) + self.midiEdit = MIDIMessageEdit(PortDirection.Output, parent=self) self.layout().addWidget(self.midiEdit) def enableCheck(self, enabled): @@ -69,7 +76,10 @@ def enableCheck(self, enabled): def getSettings(self): if self.isGroupEnabled(self.midiEdit.msgGroup): - return {"message": midi_dict_to_str(self.midiEdit.getMessageDict())} + return { + "patch_id": self.midiEdit.getPatchId(), + "message": midi_dict_to_str(self.midiEdit.getMessageDict()), + } return {} @@ -77,6 +87,9 @@ def loadSettings(self, settings): message = settings.get("message", "") if message: self.midiEdit.setMessageDict(midi_str_to_dict(message)) + patch_id = settings.get("patch_id", "") + if patch_id: + self.midiEdit.setPatchId(patch_id) CueSettingsRegistry().add(MidiCueSettings, MidiCue) diff --git a/lisp/plugins/midi/midi_io.py b/lisp/plugins/midi/midi_io.py index c315822b1..3bc34d976 100644 --- a/lisp/plugins/midi/midi_io.py +++ b/lisp/plugins/midi/midi_io.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2019 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -27,11 +27,12 @@ class MIDIBase(ABC): - def __init__(self, backend, port_name=None): + def __init__(self, backend, patch_id, port_name=None): """ :param port_name: the port name """ self._backend = backend + self._patch_id = patch_id self._port_name = port_name self._port = None @@ -49,10 +50,11 @@ def port_name(self, real=True): else: return self._port_name - def change_port(self, port_name): + def change_port(self, port_name, auto_open=True): self._port_name = port_name self.close() - self.open() + if auto_open: + self.open() @abstractmethod def open(self): @@ -91,9 +93,10 @@ class MIDIInput(MIDIBase): def __init__(self, *args): super().__init__(*args) - self.alternate_mode = False + self.exclusive_mode = False + self.received = Signal() self.new_message = Signal() - self.new_message_alt = Signal() + self.received_exclusive = Signal() def open(self): try: @@ -118,7 +121,8 @@ def __new_message(self, message): } ) - if self.alternate_mode: - self.new_message_alt.emit(message) + if self.exclusive_mode: + self.received_exclusive.emit(self._patch_id, message) else: + self.received.emit(self._patch_id, message) self.new_message.emit(message) diff --git a/lisp/plugins/midi/midi_io_device_model.py b/lisp/plugins/midi/midi_io_device_model.py new file mode 100644 index 000000000..ef405f451 --- /dev/null +++ b/lisp/plugins/midi/midi_io_device_model.py @@ -0,0 +1,253 @@ +# This file is part of Linux Show Player +# +# Copyright 2023 Francesco Ceruti +# +# Linux Show Player 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. +# +# Linux Show Player 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 Linux Show Player. If not, see . + +from abc import abstractmethod +import logging + +from PyQt5.QtCore import ( + QAbstractTableModel, + QModelIndex, + Qt, + QT_TRANSLATE_NOOP, +) + +from lisp.plugins import get_plugin +from lisp.plugins.midi.midi_io import MIDIInput, MIDIOutput +from lisp.plugins.midi.midi_utils import ( + DEFAULT_DEVICE_NAME, + MAX_MIDI_DEVICES, + midi_input_names, + midi_output_names, + PortDirection, + PortNameMatch, + PortStatus, +) +from lisp.ui.ui_utils import translate + + +logger = logging.getLogger(__name__) + + +class MidiIODeviceModel(QAbstractTableModel): + + def __init__(self): + super().__init__() + + self._direction = PortDirection.Input + self.patches = [] + self.columns = [ + translate("MIDISettings", "#"), + translate("MIDISettings", "Device"), + translate("MIDISettings", "Status"), + ] + self.used_numids = [] + self.removed_numids = [] + + def appendPatch(self, port_name=DEFAULT_DEVICE_NAME, numid=0): + if len(self.used_numids) == MAX_MIDI_DEVICES: + logger.warning("Arbitrary maximum number of MIDI devices reached.") + return + + if numid == 0: + for candidate in range(1, MAX_MIDI_DEVICES + 1): + if candidate not in self.used_numids: + numid = candidate + break + elif numid in self.used_numids: + logger.warning("Provided ID already used. Refusing to add.") + return + + if numid in self.removed_numids: + self.removed_numids.remove(numid) + + self.used_numids.append(numid) + self.used_numids.sort() + + row = self.used_numids.index(numid) + self.beginInsertRows(QModelIndex(), row, row) + patch = { + "id": numid, + "device": port_name, + "status": self.portStatus(numid, port_name), + "name_match": self.portNameMatch(numid, port_name), + } + self.patches.insert(row, patch) + self.endInsertRows() + + def columnCount(self, parent=QModelIndex()): + return len(self.columns) + + def data(self, index, role=Qt.DisplayRole): + if index.isValid(): + column = index.column() + patch = self.patches[index.row()] + + if role == Qt.TextAlignmentRole: + if column == 1: + return Qt.AlignLeft | Qt.AlignVCenter + else: + return Qt.AlignCenter + + if role == Qt.DisplayRole: + if column == 0: + return patch["id"] + elif column == 1: + prefix = "" + if patch["name_match"] is PortNameMatch.FuzzyMatch: + prefix = "~ " + elif patch["name_match"] is PortNameMatch.NoMatch: + prefix = "* " + return f'{prefix} {patch["device"]}' + elif column == 2: + return patch["status"].value + + if role == Qt.EditRole and column == 1: + return patch["device"] + + def deserialise(self, settings): + if isinstance(settings, str): + self.appendPatch(settings) + else: + for patch_id, device in settings.items(): + if device is not None: + if device == "": + device = DEFAULT_DEVICE_NAME + self.appendPatch(device, int(patch_id.split('#')[1])) + + def flags(self, index): + column = index.column() + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if column == 1: + flags |= Qt.ItemIsEditable + + return flags + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + if section < len(self.columns): + return self.columns[section] + else: + return section + 1 + + @abstractmethod + def portStatus(self, numid, port_name): + pass + + def removePatchAtIndex(self, index): + if not index.isValid(): + return + row = index.row() + self.beginRemoveRows(QModelIndex(), row, row) + patch = self.patches.pop(row) + self.removed_numids.append(patch["id"]) + self.used_numids.remove(patch["id"]) + self.endRemoveRows() + + def rowCount(self, parent=QModelIndex()): + return len(self.patches) + + def serialise(self): + patches = {} + for row in self.patches: + patches[f'{self._direction.value}#{row["id"]}'] = \ + '' if row["device"] == DEFAULT_DEVICE_NAME else row["device"] + for numid in self.removed_numids: + patches[f'{self._direction.value}#{numid}'] = None + return patches + + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid() or role != Qt.EditRole: + return False + + column = index.column() + if column != 1: + return False + + row = index.row() + patch = self.patches[row] + patch["device"] = value + patch["name_match"] = self.portNameMatch(patch["id"], value) + + status_updated = self._updateStatus(row) + self.dataChanged.emit( + self.index(row, 1), # from top-left + self.index(row, 2 if status_updated else 1), # to bottom-right + [Qt.DisplayRole, Qt.EditRole], + ) + return True + + def _updateNameMatch(self, row): + patch = self.patches[row] + match = self.portNameMatch(patch["id"], patch["device"]) + if match != patch["name_match"]: + patch["name_match"] = match + return True + return False + + def _updateStatus(self, row): + patch = self.patches[row] + status = self.portStatus(patch["id"], patch["device"]) + if status != patch["status"]: + patch["status"] = status + return True + return False + + def updateStatuses(self): + for row in range(len(self.patches)): + col = False + if self._updateNameMatch(row): + col = [1, 1] + if self._updateStatus(row): + col = [col[0] if col else 2, 2] + if col: + self.dataChanged.emit( + self.index(row, col[0]), # from top-left + self.index(row, col[1]), # to bottom-right + [Qt.DisplayRole], + ) + +class MidiInputDeviceModel(MidiIODeviceModel): + + def portStatus(self, numid, port_name): + patch_id = f"{self._direction.value}#{numid}" + plugin = get_plugin("Midi") + status = plugin.input_status(patch_id) + return status + + def portNameMatch(self, numid, port_name): + patch_id = f"{self._direction.value}#{numid}" + plugin = get_plugin("Midi") + status = plugin.input_name_match(patch_id, port_name) + return status + +class MidiOutputDeviceModel(MidiIODeviceModel): + + def __init__(self): + super().__init__() + self._direction = PortDirection.Output + + def portStatus(self, numid, port_name): + patch_id = f"{self._direction.value}#{numid}" + plugin = get_plugin("Midi") + status = plugin.output_status(patch_id) + return status + + def portNameMatch(self, numid, port_name): + patch_id = f"{self._direction.value}#{numid}" + plugin = get_plugin("Midi") + status = plugin.output_name_match(patch_id, port_name) + return status diff --git a/lisp/plugins/midi/midi_io_device_view.py b/lisp/plugins/midi/midi_io_device_view.py new file mode 100644 index 000000000..17aea437a --- /dev/null +++ b/lisp/plugins/midi/midi_io_device_view.py @@ -0,0 +1,95 @@ +# This file is part of Linux Show Player +# +# Copyright 2023 Francesco Ceruti +# +# Linux Show Player 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. +# +# Linux Show Player 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 Linux Show Player. If not, see . + +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QHeaderView, QTableView + +from lisp.plugins.midi.midi_utils import ( + DEFAULT_DEVICE_NAME, + MAX_MIDI_DEVICES, + PortNameMatch, +) +from lisp.ui.qdelegates import ( + ComboBoxDelegate, + LabelDelegate, + SpinBoxDelegate, +) + + +class MidiIODeviceView(QTableView): + + # Deliberately not using LiSP's Signal class, as that doesn't like being + # connected to the setEnabled method of an instance of Qt5's PushButton. + hasSelectionChange = pyqtSignal(bool) + + def __init__(self, model, **kwargs): + super().__init__(**kwargs) + + self.delegates = [ + SpinBoxDelegate(minimum=1, maximum=MAX_MIDI_DEVICES), + ComboBoxDelegate(), + LabelDelegate(), + ] + + self.setSelectionBehavior(QTableView.SelectRows) + self.setSelectionMode(QTableView.SingleSelection) + + self.setShowGrid(False) + self.setAlternatingRowColors(True) + + self.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) + + self.verticalHeader().setVisible(False) + self.verticalHeader().sectionResizeMode(QHeaderView.Fixed) + self.verticalHeader().setDefaultSectionSize(24) + self.verticalHeader().setHighlightSections(False) + + self.setModel(model) + + for column, delegate in enumerate(self.delegates): + self.setItemDelegateForColumn(column, delegate) + + self.horizontalHeader().resizeSection(0, 32) + self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.horizontalHeader().resizeSection(2, 64) + + def setOptions(self, midi_devices): + midi_devices = list(set(midi_devices)) # Remove duplicates + midi_devices.sort() + self.delegates[1].options = [DEFAULT_DEVICE_NAME] + midi_devices + + def ensureOptionExists(self, entry): + if entry not in self.delegates[1].options: + self.delegates[1].options.insert(1, entry) + + def ensureOptionsExist(self, options): + new_options = [] + model = self.model() + for patch_id, device_name in options.items(): + if device_name is None or device_name == "" or device_name in new_options or device_name in self.delegates[1].options: + continue + new_options.append(device_name) + new_options.sort(reverse=True) + for option in new_options: + self.delegates[1].options.insert(1, option) + + def removeSelectedPatch(self): + self.model().removePatchAtIndex(self.currentIndex()) + + def selectionChanged(self, selected, deselected): + super().selectionChanged(selected, deselected) + self.hasSelectionChange.emit(bool(selected.indexes())) diff --git a/lisp/plugins/midi/midi_settings.py b/lisp/plugins/midi/midi_settings.py index 7cc264858..5b0ad858b 100644 --- a/lisp/plugins/midi/midi_settings.py +++ b/lisp/plugins/midi/midi_settings.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2018 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -23,11 +23,14 @@ QGridLayout, QLabel, QCheckBox, + QPushButton, QSpacerItem, ) from lisp.plugins import get_plugin from lisp.plugins.midi.midi_utils import midi_input_names, midi_output_names +from lisp.plugins.midi.midi_io_device_model import MidiInputDeviceModel, MidiOutputDeviceModel +from lisp.plugins.midi.midi_io_device_view import MidiIODeviceView from lisp.ui.icons import IconTheme from lisp.ui.settings.pages import SettingsPage from lisp.ui.ui_utils import translate @@ -35,47 +38,51 @@ class MIDISettings(SettingsPage): Name = QT_TRANSLATE_NOOP("SettingsPageName", "MIDI settings") - STATUS_SYMBOLS = {True: "✓", False: "×"} # U+2713, U+00D7 def __init__(self, **kwargs): super().__init__(**kwargs) self.setLayout(QVBoxLayout()) self.layout().setAlignment(Qt.AlignTop) - self.portsGroup = QGroupBox(self) - self.portsGroup.setLayout(QGridLayout()) - self.layout().addWidget(self.portsGroup) - - # Input port - self.inputLabel = QLabel(self.portsGroup) - self.portsGroup.layout().addWidget(self.inputLabel, 0, 0, 2, 1) - - self.inputCombo = QComboBox(self.portsGroup) - self.portsGroup.layout().addWidget(self.inputCombo, 0, 1) - - self.inputStatus = QLabel(self.portsGroup) - self.inputStatus.setDisabled(True) - self.inputStatus.setText(f"[{MIDISettings.STATUS_SYMBOLS[False]}]") - self.portsGroup.layout().addWidget(self.inputStatus, 1, 1) - - # Spacer - self.portsGroup.layout().addItem(QSpacerItem(0, 30), 2, 0, 2, 1) - - # Output port - self.outputLabel = QLabel(self.portsGroup) - self.portsGroup.layout().addWidget(self.outputLabel, 3, 0, 2, 1) - - self.outputCombo = QComboBox(self.portsGroup) - self.portsGroup.layout().addWidget(self.outputCombo, 3, 1) - - self.outputStatus = QLabel(self.portsGroup) - self.outputStatus.setDisabled(True) - self.outputStatus.setText(f"[{MIDISettings.STATUS_SYMBOLS[False]}]") - self.portsGroup.layout().addWidget(self.outputStatus, 4, 1) - - self.portsGroup.layout().setColumnStretch(0, 2) - self.portsGroup.layout().setColumnStretch(1, 3) - + # Input patches + self.inputGroup = QGroupBox(self) + self.inputGroup.setLayout(QGridLayout()) + self.layout().addWidget(self.inputGroup) + + self.inputModel = MidiInputDeviceModel() + self.inputView = MidiIODeviceView(self.inputModel, parent=self.inputGroup) + self.inputGroup.layout().addWidget(self.inputView, 0, 0, 1, 2) + + self.inputAddButton = QPushButton(parent=self.inputGroup) + self.inputAddButton.pressed.connect(self.inputModel.appendPatch) + self.inputGroup.layout().addWidget(self.inputAddButton, 1, 0) + + self.inputRemButton = QPushButton(parent=self.inputGroup) + self.inputRemButton.setEnabled(False) + self.inputView.hasSelectionChange.connect(self.inputRemButton.setEnabled) + self.inputRemButton.pressed.connect(self.inputView.removeSelectedPatch) + self.inputGroup.layout().addWidget(self.inputRemButton, 1, 1) + + # Output patches + self.outputGroup = QGroupBox(self) + self.outputGroup.setLayout(QGridLayout()) + self.layout().addWidget(self.outputGroup) + + self.outputModel = MidiOutputDeviceModel() + self.outputView = MidiIODeviceView(self.outputModel, parent=self.outputGroup) + self.outputGroup.layout().addWidget(self.outputView, 0, 0, 1, 2) + + self.outputAddButton = QPushButton(parent=self.outputGroup) + self.outputAddButton.pressed.connect(self.outputModel.appendPatch) + self.outputGroup.layout().addWidget(self.outputAddButton, 1, 0) + + self.outputRemButton = QPushButton(parent=self.outputGroup) + self.outputRemButton.setEnabled(False) + self.outputView.hasSelectionChange.connect(self.outputRemButton.setEnabled) + self.outputRemButton.pressed.connect(self.outputView.removeSelectedPatch) + self.outputGroup.layout().addWidget(self.outputRemButton, 1, 1) + + # Match by name self.miscGroup = QGroupBox(self) self.miscGroup.setLayout(QVBoxLayout()) self.layout().addWidget(self.miscGroup) @@ -97,9 +104,13 @@ def __init__(self, **kwargs): self.updateTimer.start(2000) def retranslateUi(self): - self.portsGroup.setTitle(translate("MIDISettings", "MIDI devices")) - self.inputLabel.setText(translate("MIDISettings", "Input")) - self.outputLabel.setText(translate("MIDISettings", "Output")) + self.inputGroup.setTitle(translate("MIDISettings", "MIDI Inputs")) + self.inputAddButton.setText(translate("MIDISettings", "Add")) + self.inputRemButton.setText(translate("MIDISettings", "Remove")) + + self.outputGroup.setTitle(translate("MIDISettings", "MIDI Outputs")) + self.outputAddButton.setText(translate("MIDISettings", "Add")) + self.outputRemButton.setText(translate("MIDISettings", "Remove")) self.miscGroup.setTitle(translate("MIDISettings", "Misc options")) self.nameMatchCheckBox.setText( @@ -109,21 +120,19 @@ def retranslateUi(self): ) def loadSettings(self, settings): - if settings["inputDevice"]: - self.inputCombo.setCurrentText(settings["inputDevice"]) - if self.inputCombo.currentText() != settings["inputDevice"]: - self.inputCombo.insertItem( - 1, IconTheme.get("dialog-warning"), settings["inputDevice"] - ) - self.inputCombo.setCurrentIndex(1) - - if settings["outputDevice"]: - self.outputCombo.setCurrentText(settings["outputDevice"]) - if self.outputCombo.currentText() != settings["outputDevice"]: - self.outputCombo.insertItem( - 1, IconTheme.get("dialog-warning"), settings["outputDevice"] - ) - self.outputCombo.setCurrentIndex(1) + if "inputDevices" in settings: + self.inputModel.deserialise(settings["inputDevices"]) + self.inputView.ensureOptionsExist(settings["inputDevices"]) + elif "inputDevice" in settings: + self.inputModel.deserialise(settings["inputDevice"]) + self.inputView.ensureOptionExists(settings["inputDevice"]) + + if "outputDevices" in settings: + self.outputModel.deserialise(settings["outputDevices"]) + self.outputView.ensureOptionsExist(settings["outputDevices"]) + elif "outputDevice" in settings: + self.outputModel.deserialise(settings["outputDevice"]) + self.outputView.ensureOptionExists(settings["outputDevice"]) self.nameMatchCheckBox.setChecked( settings.get("connectByNameMatch", False) @@ -131,39 +140,20 @@ def loadSettings(self, settings): def getSettings(self): if self.isEnabled(): - input = self.inputCombo.currentText() - output = self.outputCombo.currentText() - return { - "inputDevice": "" if input == "Default" else input, - "outputDevice": "" if output == "Default" else output, + "inputDevices": self.inputModel.serialise(), + "outputDevices": self.outputModel.serialise(), "connectByNameMatch": self.nameMatchCheckBox.isChecked(), } - return {} - @staticmethod - def portStatusSymbol(port): - return MIDISettings.STATUS_SYMBOLS.get(port.is_open(), "") - def _updatePortsStatus(self): - midi = get_plugin("Midi") - - self.inputStatus.setText( - f"[{self.portStatusSymbol(midi.input)}] {midi.input.port_name()}" - ) - self.outputStatus.setText( - f"[{self.portStatusSymbol(midi.output)}] {midi.output.port_name()}" - ) + self.inputModel.updateStatuses() + self.outputModel.updateStatuses() def _loadDevices(self): - self.inputCombo.clear() - self.inputCombo.addItems(["Default"]) - self.inputCombo.addItems(midi_input_names()) - - self.outputCombo.clear() - self.outputCombo.addItems(["Default"]) - self.outputCombo.addItems(midi_output_names()) + self.inputView.setOptions(midi_input_names()) + self.outputView.setOptions(midi_output_names()) def _update(self): if self.isVisible(): diff --git a/lisp/plugins/midi/midi_utils.py b/lisp/plugins/midi/midi_utils.py index eb3e26aa2..ac3261e15 100644 --- a/lisp/plugins/midi/midi_utils.py +++ b/lisp/plugins/midi/midi_utils.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2019 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,11 +15,14 @@ # You should have received a copy of the GNU General Public License # along with Linux Show Player. If not, see . +from enum import Enum from typing import Iterable import mido from PyQt5.QtCore import QT_TRANSLATE_NOOP +from lisp.ui.ui_utils import translate + MIDI_MSGS_SPEC = { "note_on": ["channel", "note", "velocity"], "note_off": ["channel", "note", "velocity"], @@ -77,6 +80,36 @@ } +DEFAULT_DEVICE_NAME = "Default" +MAX_MIDI_DEVICES = 16 # 16 ins, 16 outs. + + +class PortDirection(Enum): + Input = "in" + Output = "out" + + +class PortNameMatch(Enum): + NoMatch = 0 + ExactMatch = 1 + FuzzyMatch = -1 + + +class PortStatus(Enum): + Open = "✓" # U+2713 + Closed = "×" # U+00D7 + DoesNotExist = "~" + + +def format_patch_name(patch_id, device_name): + split_id = patch_id.split('#') + if split_id[0] == PortDirection.Input.value: + text = translate("MIDIInfo", "In {}: {}") + else: + text = translate("MIDIInfo", "Out {}: {}") + return text.format(split_id[1], device_name or DEFAULT_DEVICE_NAME) + + def midi_backend() -> mido.Backend: """Return the current backend object.""" backend = None diff --git a/lisp/plugins/midi/widgets.py b/lisp/plugins/midi/widgets.py index 289f46da5..b08f683ac 100644 --- a/lisp/plugins/midi/widgets.py +++ b/lisp/plugins/midi/widgets.py @@ -1,6 +1,6 @@ # This file is part of Linux Show Player # -# Copyright 2019 Francesco Ceruti +# Copyright 2023 Francesco Ceruti # # Linux Show Player is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,6 +15,8 @@ # You should have received a copy of the GNU General Public License # along with Linux Show Player. If not, see . +from abc import abstractmethod + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QGroupBox, @@ -29,15 +31,45 @@ QDialogButtonBox, ) +from lisp.plugins import get_plugin from lisp.plugins.midi.midi_utils import ( MIDI_MSGS_SPEC, MIDI_ATTRS_SPEC, MIDI_MSGS_NAME, MIDI_ATTRS_NAME, + PortDirection, ) from lisp.ui.ui_utils import translate +class MIDIPatchCombo(QComboBox): + def __init__(self, direction: PortDirection, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__direction = direction + self.__midi = get_plugin("Midi") + if self.__midi.is_loaded(): + for patch_id in self._patches(): + self.addItem("", patch_id) + + def _patches(self): + if self.__direction is PortDirection.Input: + return self.__midi.input_patches() + return self.__midi.output_patches() + + def _patch_name(self, patch_id): + if self.__direction is PortDirection.Input: + return self.__midi.input_name_formatted(patch_id) + return self.__midi.output_name_formatted(patch_id) + + def retranslateUi(self): + if self.__midi.is_loaded(): + for patch_id, device_name in self._patches().items(): + self.setItemText( + self.findData(patch_id), + self._patch_name(patch_id) + ) + + class MIDIMessageEdit(QWidget): """ To reference naming and values see: @@ -45,7 +77,7 @@ class MIDIMessageEdit(QWidget): https://www.midi.org/specifications-old/item/table-1-summary-of-midi-message """ - def __init__(self, **kwargs): + def __init__(self, direction: PortDirection, **kwargs): super().__init__(**kwargs) self.setLayout(QVBoxLayout()) self.layout().setAlignment(Qt.AlignTop) @@ -54,25 +86,31 @@ def __init__(self, **kwargs): self.msgGroup.setLayout(QGridLayout()) self.layout().addWidget(self.msgGroup) + # Device patch + self.msgPatchLabel = QLabel(self.msgGroup) + self.msgGroup.layout().addWidget(self.msgPatchLabel, 0, 0) + self.msgPatchCombo = MIDIPatchCombo(direction, self.msgGroup) + self.msgGroup.layout().addWidget(self.msgPatchCombo, 0, 1) + # Message type self.msgTypeLabel = QLabel(self.msgGroup) - self.msgGroup.layout().addWidget(self.msgTypeLabel, 0, 0) + self.msgGroup.layout().addWidget(self.msgTypeLabel, 1, 0) self.msgTypeCombo = QComboBox(self.msgGroup) for msgType in MIDI_MSGS_SPEC.keys(): self.msgTypeCombo.addItem( translate("MIDIMessageType", MIDI_MSGS_NAME[msgType]), msgType ) self.msgTypeCombo.currentIndexChanged.connect(self._typeChanged) - self.msgGroup.layout().addWidget(self.msgTypeCombo, 0, 1) + self.msgGroup.layout().addWidget(self.msgTypeCombo, 1, 1) line = QFrame(self.msgGroup) line.setFrameShape(QFrame.HLine) line.setFrameShadow(QFrame.Sunken) - self.msgGroup.layout().addWidget(line, 1, 0, 1, 2) + self.msgGroup.layout().addWidget(line, 2, 0, 1, 2) # Data widgets self._dataWidgets = [] - for n in range(2, 5): + for n in range(3, 6): dataLabel = QLabel(self.msgGroup) dataSpin = QSpinBox(self.msgGroup) @@ -86,8 +124,18 @@ def __init__(self, **kwargs): def retranslateUi(self): self.msgGroup.setTitle(translate("MIDICue", "MIDI Message")) + self.msgPatchLabel.setText(translate("MIDICue", "MIDI Patch")) + self.msgPatchCombo.retranslateUi() self.msgTypeLabel.setText(translate("MIDICue", "Message type")) + def getPatchId(self): + return self.msgPatchCombo.currentData() + + def setPatchId(self, patch_id): + self.msgPatchCombo.setCurrentIndex( + self.msgPatchCombo.findData(patch_id) + ) + def getMessageDict(self): msgType = self.msgTypeCombo.currentData() msgDict = {"type": msgType} @@ -132,11 +180,11 @@ def _typeChanged(self): class MIDIMessageEditDialog(QDialog): - def __init__(self, **kwargs): + def __init__(self, direction: PortDirection, **kwargs): super().__init__(**kwargs) self.setLayout(QVBoxLayout()) - self.editor = MIDIMessageEdit() + self.editor = MIDIMessageEdit(direction) self.layout().addWidget(self.editor) self.buttons = QDialogButtonBox( @@ -151,3 +199,9 @@ def getMessageDict(self): def setMessageDict(self, dictMsg): self.editor.setMessageDict(dictMsg) + + def getPatchId(self): + return self.editor.getPatchId() + + def setPatchId(self, patch_id): + return self.editor.setPatchId(patch_id)