Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support more than one MIDI In/Out #297

Open
wants to merge 28 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a0c87a2
Support having more than one Midi In/Out port open
s0600204 Aug 18, 2023
3633bc9
Change to dict instead of list
s0600204 Aug 18, 2023
ed79241
Begin the UI with which multiple MIDI devices may be patched
s0600204 Aug 18, 2023
c6587eb
Choose which device is used when sending MIDI messages
s0600204 Aug 18, 2023
1304504
Split MIDI Patch ComboBox into a standalone widget
s0600204 Aug 24, 2023
8b6e7ca
Add a "received" signal to the MIDI plugin
s0600204 Aug 24, 2023
946679b
Rewrite exclusive capturing from a specific MIDI Input
s0600204 Aug 24, 2023
d185c5a
Update the Controller plugin's support for MIDI
s0600204 Aug 26, 2023
86e90c9
Don't conflate name matching and port status
s0600204 Aug 26, 2023
d90edb3
Ensure that the currently selected device is an option
s0600204 Aug 26, 2023
ee91a2c
Remove duplicates in list of midi devices
s0600204 Aug 26, 2023
de2d0f5
Switch to using the new table for selecting devices
s0600204 Aug 26, 2023
0adb644
Remove old device setting UI
s0600204 Aug 26, 2023
84b36fa
Use the PortDirection enum to define the prefix for device ids
s0600204 Aug 26, 2023
c63d601
Support removing MIDI patches again
s0600204 Aug 28, 2023
bee55e9
Filter out Null'd device earlier
s0600204 Aug 30, 2023
b31abb6
Add helper function and methods to aid formatting patch name
s0600204 Aug 30, 2023
4a1476a
Remove attributes and default arguments that should not be needed
s0600204 Aug 30, 2023
d045a11
Update default configuration json
s0600204 Aug 30, 2023
8bae30d
Improve adding of absent devices in MIDI settings
s0600204 Aug 30, 2023
0630413
Change name formatting to be more user friendly
s0600204 Aug 31, 2023
ccc6f27
Alias "Default" device name
s0600204 Nov 26, 2023
b50c775
Correct reverse-compatibility fallback
s0600204 Nov 26, 2023
4f295dd
Support changing to a (currently) non-available MIDI in/out
s0600204 Nov 26, 2023
73874b3
Show "Default" in patch selection lists
s0600204 Nov 26, 2023
3221c34
Use the Patch Name (shortened) in the Controller UI
s0600204 Nov 26, 2023
4c90177
Use a better way of formatting patch name in Patch dropdowns
s0600204 Nov 26, 2023
bcfd7af
Fix errors when MIDI plugin not installed/loaded
s0600204 Jan 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lisp/core/signal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This file is part of Linux Show Player
#
# Copyright 2016 Francesco Ceruti <[email protected]>
# Copyright 2023 Francesco Ceruti <[email protected]>
#
# 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
Expand Down Expand Up @@ -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)
153 changes: 103 additions & 50 deletions lisp/plugins/controller/protocols/midi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This file is part of Linux Show Player
#
# Copyright 2016 Francesco Ceruti <[email protected]>
# Copyright 2023 Francesco Ceruti <[email protected]>
#
# 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
Expand Down Expand Up @@ -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,
Expand All @@ -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__)

Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -113,35 +131,43 @@ 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"))

self.midiCapture.setText(translate("ControllerMidiSettings", "Capture"))
self.filterLabel.setText(
translate("ControllerMidiSettings", "Capture filter")
)
self.filterPatchCombo.retranslateUi()

def enableCheck(self, enabled):
self.setGroupEnabled(self.midiGroup, 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(
Expand All @@ -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())
Expand Down Expand Up @@ -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:
Expand All @@ -243,43 +269,63 @@ class MidiModel(SimpleTableModel):
def __init__(self):
super().__init__(
[
translate("ControllerMidiSettings", "MIDI Patch"),
translate("ControllerMidiSettings", "Type"),
translate("ControllerMidiSettings", "Data 1"),
translate("ControllerMidiSettings", "Data 2"),
translate("ControllerMidiSettings", "Data 3"),
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(),
Expand Down Expand Up @@ -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
)


Expand All @@ -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)}")
6 changes: 3 additions & 3 deletions lisp/plugins/midi/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"_version_": "2.1",
"_enabled_": true,
"backend": "mido.backends.rtmidi",
"inputDevice": "",
"outputDevice": "",
"inputDevices": {},
"outputDevices": {},
"connectByNameMatch": true
}
}
Loading