From abff3df5f390ddd677a28538b2b272bb68c222f1 Mon Sep 17 00:00:00 2001 From: Nils <> Date: Thu, 11 Nov 2021 21:10:57 +0100 Subject: [PATCH] New generic midi in quick connector widget --- template/engine/api.py | 9 +- template/engine/data.py | 11 +++ template/engine/sampler_sf2.py | 21 +++- template/qtgui/midiinquickwidget.py | 146 ++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 template/qtgui/midiinquickwidget.py diff --git a/template/engine/api.py b/template/engine/api.py index 7bc3f03..df252a5 100644 --- a/template/engine/api.py +++ b/template/engine/api.py @@ -271,6 +271,7 @@ class Callbacks(object): func(channel, exportDict) def _ignoreProgramChangesChanged(self): + #TODO: this only checks the first layer. There might be a second one as well. fine.jpg state = session.data.midiInput.scene.status().layers[0].status().ignore_program_changes for func in self.ignoreProgramChangesChanged: func(state) @@ -280,6 +281,7 @@ class Callbacks(object): for func in self.channelActivity: func(channel) + def startEngine(nsmClient): """ This function gets called after initializing the GUI, calfbox @@ -342,6 +344,8 @@ def getUndoLists(): #undoHistory, redoHistory = session.history.asList() return session.history.asList() +def getMidiInputNameAndUuid(): + return session.data.getMidiInputNameAndUuid() #tuple name:str, uuid #Calfbox Sequencer Controls def playbackStatus()->bool: @@ -443,8 +447,9 @@ def loadSoundfont(filePath): def setIgnoreProgramAndBankChanges(state): state = bool(state) #there is no session wrapper function. we use cbox directly. Save file and callbacks will fetch the current value on its own - session.data.midiInput.scene.status().layers[0].set_ignore_program_changes(state) - assert session.data.midiInput.scene.status().layers[0].status().ignore_program_changes == state + for layer in session.data.midiInput.scene.status().layers: #midi in[0] and real time midi thru[1] + layer.set_ignore_program_changes(state) + assert layer.status().ignore_program_changes == state callbacks._ignoreProgramChangesChanged() callbacks._dataChanged() diff --git a/template/engine/data.py b/template/engine/data.py index 168bd8d..bae5783 100644 --- a/template/engine/data.py +++ b/template/engine/data.py @@ -36,6 +36,17 @@ class Data(object): def updateJackMetadataSorting(self): pass + def getMidiInputNameAndUuid(self): + """ + Return name and cboxMidiPortUid. + name is Client:Port JACK format + + Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and + sequencers. Used by the quick connect midi input widget. + If double None as return the widget in the GUI might hide and deactivate itself.""" + + return None, None + #Save / Load / Export def serialize(self)->dict: return { diff --git a/template/engine/sampler_sf2.py b/template/engine/sampler_sf2.py index f6fd675..120c5bf 100644 --- a/template/engine/sampler_sf2.py +++ b/template/engine/sampler_sf2.py @@ -54,7 +54,8 @@ class Sampler_sf2(Data): self.buffer_ignoreProgramChanges = ignoreProgramChanges self.midiInput = MidiInput(session=parentSession, portName="all") - cbox.JackIO.Metadata.set_port_order(f"{cbox.JackIO.status().client_name}:all", 0) #first one. The other port order later start at 1 because we loop 1-based for channels. + self.midiInputJackName = f"{cbox.JackIO.status().client_name}:all" + cbox.JackIO.Metadata.set_port_order(self.midiInputJackName, 0) #first one. The other port order later start at 1 because we loop 1-based for channels. #Set libfluidsynth! to 16 output pairs. We prepared 32 jack ports in the session start. "soundfont" is our given name, in the line below. This is a prepared config which will be looked up by add_new_instrument cbox.Config.set("instrument:soundfont", "engine", "fluidsynth") @@ -112,7 +113,9 @@ class Sampler_sf2(Data): logger.error(e) #If demanded create 16 routing midi channels, basically a jack port -> midi channel merger. - if True: + #TODO: This is not working. Better leave that to JACK itself and an outside program for now. + #Also deactivated the pretty name metadata update call in updateChannelMidiInJackMetadaPrettyname. It is called by an api callback and by our "all" function + if False: for midiRouterPortNum in range(1,17): portName = f"channel_{str(midiRouterPortNum).zfill(2)}" router_cboxMidiPortUid = cbox.JackIO.create_midi_input(portName) @@ -289,6 +292,7 @@ class Sampler_sf2(Data): def updateChannelMidiInJackMetadaPrettyname(self, channel:int): """Midi in single channels 1-16""" + return #TODO: Until this works. bank, program, name = self.activePatches()[channel] #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index. portName = f"channel_{str(channel).zfill(2)}" @@ -326,6 +330,19 @@ class Sampler_sf2(Data): cbox.JackIO.Metadata.set_pretty_name(portnameR, f"{str(channel).zfill(2)}-R : {name}") """ + + def getMidiInputNameAndUuid(self): + """ + Return name and cboxMidiPortUid. + name is Client:Port JACK format + + Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and + sequencers. Used by the quick connect midi input widget. + If double None as return the widget in the GUI might hide and deactivate itself.""" + return self.midiInputJackName, self.midiInput.cboxMidiPortUid + + + #Save / Load / Export def serialize(self)->dict: self.unlinkUnusedSoundfonts() #We do not want them to be in the save file. diff --git a/template/qtgui/midiinquickwidget.py b/template/qtgui/midiinquickwidget.py new file mode 100644 index 0000000..f483e6e --- /dev/null +++ b/template/qtgui/midiinquickwidget.py @@ -0,0 +1,146 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), + +This application 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; logging.info("import {}".format(__file__)) + +#Standard Library Modules + +#Third Party Modules +from PyQt5 import QtWidgets, QtCore, QtGui +from calfbox import cbox + +#Template Modules + +#Our modules +import engine.api as api + + +class QuickMidiInputComboController(QtWidgets.QWidget): + """This widget breaks a bit with the convention of engine/gui. However, it is a pure convenience + fire-and-forget function with no callbacks.""" + + def __init__(self, parentWidget): + super().__init__(parentWidget) + self.parentWidget = parentWidget + self.layout = QtWidgets.QHBoxLayout() + self.setLayout(self.layout) + + self.comboBox = QtWidgets.QComboBox(self) + self.layout.addWidget(self.comboBox) + """ + self.volumeDial = parentWidget.ui.auditionerVolumeDial + self.volumeDial.setMaximum(0) + self.volumeDial.setMinimum(-21) + self.volumeDial.valueChanged.connect(self._sendVolumeChangeToEngine) + self.volumeDial.enterEvent = lambda ev: self.parentWidget.statusBar().showMessage((QtCore.QCoreApplication.translate("Auditioner", "Use mousewheel to change the Auditioner Volume"))) + self.volumeDial.leaveEvent = lambda ev: self.parentWidget.statusBar().showMessage("") + self.wholePanel = parentWidget.ui.auditionerWidget + """ + self.label = QtWidgets.QLabel(self) + self.label.setText(QtCore.QCoreApplication.translate("QuickMidiInputComboController", "Midi Input. Use JACK to connect multiple inputs.")) + self.layout.addWidget(self.label) + + #if not api.isStandaloneMode(): + #self.wholePanel.hide() + #return + + ##self.wholePanel.show() #explicit is better than implicit + self.originalShowPopup = self.comboBox.showPopup + self.comboBox.showPopup = self.showPopup + self.comboBox.activated.connect(self._newPortChosen) + + self.cboxPortname, self.cboxMidiPortUid = api.getMidiInputNameAndUuid() #these don't change during runtime. + + self.layout.addStretch() + #api.callbacks.startLoadingAuditionerInstrument.append(self.callback_startLoadingAuditionerInstrument) + #api.callbacks.auditionerInstrumentChanged.append(self.callback_auditionerInstrumentChanged) + #api.callbacks.auditionerVolumeChanged.append(self.callback__auditionerVolumeChanged) + + def callback_startLoadingAuditionerInstrument(self, idkey): + self.parentWidget.qtApp.setOverrideCursor(QtCore.Qt.WaitCursor) #reset in self.callback_auditionerInstrumentChanged + self.label.setText(QtCore.QCoreApplication.translate("QuickMidiInputComboController", "…loading…")) + self.parentWidget.qtApp.processEvents() #actually show the label and cursor + + def callback_auditionerInstrumentChanged(self, exportMetadata:dict): + self.parentWidget.qtApp.restoreOverrideCursor() #We assume the cursor was set to a loading animation + key = exportMetadata["id-key"] + t = f"➜ [{key[0]}-{key[1]}] {exportMetadata['name']}" + self.label.setText(t) + + def callback__auditionerVolumeChanged(self, value:float): + self.volumeDial.setValue(value) + self.parentWidget.statusBar().showMessage(QtCore.QCoreApplication.translate("QuickMidiInputComboController", "Volume: {}").format(value)) + + def _sendVolumeChangeToEngine(self, newValue): + self.volumeDial.blockSignals(True) + api.setAuditionerVolume(newValue) + self.volumeDial.blockSignals(False) + + def _newPortChosen(self, index:int): + assert self.comboBox.currentIndex() == index + self.connectMidiInputPort(self.comboBox.currentText()) + + def showPopup(self): + """When the combobox is opened quickly update the port list before showing it""" + self._fill() + self.originalShowPopup() + + def _fill(self): + self.comboBox.clear() + self.comboBox.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) + availablePorts = self.getAvailablePorts() + self.comboBox.addItem("") # Not only a more visible seaparator than the Qt one, but also doubles as "disconnect" + self.comboBox.addItems(availablePorts["hardware"]) + #self.comboBox.insertSeparator(len(availablePorts["hardware"])+1) + self.comboBox.addItem("") # Not only a more visible seaparator than the Qt one, but also doubles as "disconnect" + self.comboBox.addItems(availablePorts["software"]) + + def getAvailablePorts(self)->dict: + """This function queries JACK each time it is called. + It returns a dict with two lists. + Keys "hardware" and "software" for the type of port. + """ + result = {} + hardware = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE | cbox.JackIO.PORT_IS_PHYSICAL)) + allPorts = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE)) + software = allPorts.difference(hardware) + result["hardware"] = sorted(list(hardware)) + result["software"] = sorted(list(software)) + return result + + + def connectMidiInputPort(self, externalPort:str): + """externalPort is in the Client:Port JACK format + If "" False or None disconnect all ports.""" + + try: + currentConnectedList = cbox.JackIO.get_connected_ports(self.cboxMidiPortUid) + except: #port not found. + currentConnectedList = [] + + for port in currentConnectedList: + cbox.JackIO.port_disconnect(port, self.cboxPortname) + + if externalPort: + availablePorts = self.getAvailablePorts() + if not (externalPort in availablePorts["hardware"] or externalPort in availablePorts["software"]): + raise RuntimeError(f"QuickMidiInput was instructed to connect to port {externalPort}, which does not exist") + + cbox.JackIO.port_connect(externalPort, self.cboxPortname)