diff --git a/engine/api.py b/engine/api.py
index c3d7834..918c30f 100644
--- a/engine/api.py
+++ b/engine/api.py
@@ -53,6 +53,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
self.auditionerInstrumentChanged = []
self.auditionerVolumeChanged = []
self.instrumentMidiNoteOnActivity = []
+ self.instrumentMidiNoteOffActivity = []
def _tempCallback(self):
"""Just for copy paste during development"""
@@ -125,10 +126,13 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
for func in self.auditionerVolumeChanged:
func(export)
- def _instrumentMidiNoteOnActivity(self, libraryId:int, instrumentId:int):
- key = (libraryId, instrumentId)
+ def _instrumentMidiNoteOnActivity(self, idKey, pitch, velocity):
for func in self.instrumentMidiNoteOnActivity:
- func(key)
+ func(idKey, pitch, velocity)
+
+ def _instrumentMidiNoteOffActivity(self, idKey, pitch, velocity):
+ for func in self.instrumentMidiNoteOffActivity:
+ func(idKey, pitch, velocity)
#Inject our derived Callbacks into the parent module
template.engine.api.callbacks = ClientCallbacks()
@@ -137,7 +141,7 @@ from template.engine.api import callbacks
_templateStartEngine = startEngine
def startEngine(nsmClient, additionalData):
- session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity)
+ session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity, callbacks._instrumentMidiNoteOffActivity )
_templateStartEngine(nsmClient) #loads save files or creates empty structure.
@@ -190,7 +194,7 @@ def rescanSampleDirectory(newBaseSamplePath):
#In Tembro instruments never musically change through updates.
#There is no musical danger of keeping an old version alive, even if a newly downloaded .tar
#contains updates. A user must load/reload these manually or restart the program.
- session.data.parseAndLoadInstrumentLibraries(newBaseSamplePath, session.data.instrumentMidiNoteOnActivity)
+ session.data.parseAndLoadInstrumentLibraries(newBaseSamplePath, session.data.instrumentMidiNoteOnActivity, session.data.instrumentMidiNoteOffActivity)
callbacks._rescanSampleDir() #instructs the GUI to forget all cached data and start fresh.
callbacks._instrumentListMetadata() #Relatively quick. Happens only after a reload of the sample dir.
@@ -281,7 +285,7 @@ def setInstrumentKeySwitch(idKey:tuple, keySwitchMidiPitch:int):
#Send in any case, no matter if changed or not. Doesn't hurt.
callbacks._instrumentStatusChanged(*instrument.idKey)
-def _checkForKeySwitch(idKey:tuple):
+def _checkForKeySwitch(idKey:tuple, pitch:int, velocity:int):
"""We added this ourselves to the note-on midi callback.
So this gets called for every note-one."""
instrument = _instr(idKey)
@@ -321,3 +325,30 @@ def setAuditionerVolume(value:float):
Default is -3.0 """
session.data.auditioner.volume = value
callbacks._auditionerVolumeChanged()
+
+
+def connectInstrumentPort(idKey:tuple, externalPort:str):
+ instrument = _instr(idKey)
+ if instrument.enabled:
+ instrument.connectMidiInputPort(externalPort)
+
+
+def sendNoteOnToInstrument(idKey:tuple, midipitch:int):
+ """Not Realtime!
+ Caller is responsible to shut off the note.
+ Sends a fake midi-in callback."""
+ v = 90
+ #A midi event send to scene is different from an incoming midi event, which we use as callback trigger. We need to fake this one.
+ instrument = _instr(idKey)
+ if instrument.enabled:
+ instrument.scene.send_midi_event(0x90, midipitch, v)
+ callbacks._instrumentMidiNoteOnActivity(idKey, midipitch, v)
+
+def sendNoteOffToInstrument(idKey:tuple, midipitch:int):
+ """Not Realtime!
+ Sends a fake midi-in callback."""
+ #callbacks._stepEntryNoteOff(midipitch, 0)
+ instrument = _instr(idKey)
+ if instrument.enabled:
+ instrument.scene.send_midi_event(0x80, midipitch, 0)
+ callbacks._instrumentMidiNoteOffActivity(idKey, midipitch, 0)
diff --git a/engine/instrument.py b/engine/instrument.py
index 1df2128..f0d32b1 100644
--- a/engine/instrument.py
+++ b/engine/instrument.py
@@ -23,6 +23,7 @@ along with this program. If not, see .
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Python Standard Lib
+import re
#Third Party
from calfbox import cbox
@@ -117,9 +118,11 @@ class Instrument(object):
self.currentVariant:str = "" #This is the currently loaded variant. Only set after actual loading samples. That means it is "" even from a savefile and only set later.
#We could call self.loadSamples() now, but we delay that for the user experience. See docstring.
- self.currentVariantKeySwitches = None #set by _parseKeySwitches through chooseVariant . Ttuple: dict, sw_lokey, sw_highkey . See docstring of parseKeySwitches
+ self.currentVariantKeySwitches = None #set by _parseKeys through chooseVariant . Ttuple: dict, sw_lokey, sw_highkey . See docstring of parseKeySwitches
self.currentKeySwitch:int = None # Midi pitch. Default is set on load.
+ self.playableKeys:tuple = None #sorted tuple of ints. set by _parseKeys through chooseVariant. Set of int pitches. Used for export.
+
def exportStatus(self)->dict:
"""The call-often function to get the instrument status. Includes only data that can
actually change during runtime."""
@@ -137,6 +140,7 @@ class Instrument(object):
result["mixerLevel"] = self.mixerLevel #float.
result["keySwitches"] = self.currentVariantKeySwitches[0] if self.currentVariantKeySwitches else {} #Internally this is a tuple with [0] being a dict: Unordered!! dict with midiPitch: (opcode, label). You need the opcode to see if it is a momentary switch or permanent.
result["currentKeySwitch"] = self.currentKeySwitch
+ result["playableKeys"] = self.playableKeys
return result
def exportMetadata(self)->dict:
@@ -249,7 +253,7 @@ class Instrument(object):
dict with key=keystring e.g. c#4
and value=(opcode,label). label can be empty.
- keyswitches can only get parsed if the program is actually loaded/enabled.
+ keys can only get parsed if the program is actually loaded/enabled.
Only existing keyswitches are included, not every number from 0-127.
Two special keys "sw_lokey" and "sw_hikeys" are returned and show the total range of possible
@@ -299,6 +303,10 @@ class Instrument(object):
def findKS(data, writeInResult, writeInOthers):
if "sw_label" in data:
label = data["sw_label"]
+ #remove leading int or key from label
+ m = re.match("\d+", label)
+ if m: #could be None
+ label = label[m.span()[1]:].lstrip() #remove number and potential leading space
else:
label = ""
@@ -317,25 +325,55 @@ class Instrument(object):
midiPitch = midiName2midiPitch[data["sw_up"]]
writeInResult[midiPitch] = "sw_up", label
+ def findPlayableKeys(data:dict, writeInResult:set):
+ """Playable keys can be on any level. Mostly groups and regions though."""
+ if "key" in data:
+ notePitch:int = midiName2midiPitch[data["key"]]
+ writeInResult.add(notePitch)
+ if "lokey" in data and "hikey" in data:
+ #Guard
+ if data["lokey"] == "-1" or data["hikey"] == "-1": #"lokey and hikey to -1, to prevent a region from being triggered by any keys." https://sfzformat.com/opcodes/lokey
+ return
+
+ lower = midiName2midiPitch[data["lokey"]]
+ higher = midiName2midiPitch[data["hikey"]]
+
+ if lower > higher:
+ logger.error(f"Instrument {self.name} {self.currentVariant} SFZ problem: lokey {lower} is higher than hikey {higher}")
+ return
+ for notePitch in range(lower, higher+1):
+ writeInResult.add(notePitch)
+
logger.info(f"Start parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}")
- result = {} # int:tuple(string)
+ result = {} # int:tuple(opcode, keyswitch-label)
others = {} # var:var
- hierarchy = self.program.get_hierarchy()
+ hierarchy = self.program.get_hierarchy() #starts with global and dicts down with get_children(). First single entry layer is get_global()
+
+ allKeys = set()
+
for k,v in hierarchy.items(): #Global
globalData = k.as_dict()
swlokeyValue = globalData["sw_lokey"] if "sw_lokey" in globalData else ""
swhikeyValue = globalData["sw_hikey"] if "sw_hikey" in globalData else ""
others["sw_default"] = globalData["sw_default"] if "sw_default" in globalData else ""
for k1,v1 in v.items(): #Master
- findKS(k1.as_dict(), result, others)
+ k1AsDict = k1.as_dict()
+ findPlayableKeys(k1AsDict, allKeys)
+ findKS(k1AsDict, result, others)
if v1:
for k2,v2 in v1.items(): #Group
- findKS(k2.as_dict(), result, others)
+ k2AsDict = k2.as_dict()
+ findPlayableKeys(k2AsDict, allKeys)
+ findKS(k2AsDict, result, others)
if v2:
for k3,v3 in v2.items(): #Regions
- findKS(k3.as_dict(), result, others)
+ k3AsDict = k3.as_dict()
+ findPlayableKeys(k3AsDict, allKeys)
+ findKS(k3AsDict, result, others)
+
+ self.playableKeys = tuple(sorted(allKeys))
logger.info(f"Finished parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}. Found: {len(result)} keyswitches.")
if not result:
return None
@@ -444,11 +482,14 @@ class Instrument(object):
cbox.JackIO.set_appsink_for_midi_input(self.cboxMidiPortUid, True) #This sounds like a program wide sink, but it is needed for every port.
cbox.JackIO.route_midi_input(self.cboxMidiPortUid, self.scene.uuid) #Route midi input to the scene. Without this we have no sound, but the python processor would still work.
+ self.cboxPortname = cbox.JackIO.status().client_name + ":" + self.midiInputPortName
+
self.midiProcessor = MidiProcessor(parentInput = self) #works through self.cboxMidiPortUid
- self.midiProcessor.register_NoteOn(self.triggerActivityCallback)
+ self.midiProcessor.register_NoteOn(self.triggerNoteOnCallback)
+ self.midiProcessor.register_NoteOff(self.triggerNoteOffCallback)
#self.midiProcessor.notePrinter(True)
- self.parentLibrary.parentData.parentSession.eventLoop.slowConnect(self.midiProcessor.processEvents)
+ self.parentLibrary.parentData.parentSession.eventLoop.fastConnect(self.midiProcessor.processEvents)
self.parentLibrary.parentData.updateJackMetadataSorting()
@@ -493,12 +534,49 @@ class Instrument(object):
except: #"Router already attached"
pass
- def triggerActivityCallback(self, *args):
+ def triggerNoteOnCallback(self, timestamp, channel, pitch, velocity):
"""args are: timestamp, channel, note, velocity.
- Which we all don't need at the moment.
- If in the future we need these for a more important task than blinking an LED:
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
- self.parentLibrary.parentData.instrumentMidiNoteOnActivity(*self.idKey)
+ self.parentLibrary.parentData.instrumentMidiNoteOnActivity(self.idKey, pitch, velocity)
+
+ def triggerNoteOffCallback(self, timestamp, channel, pitch, velocity):
+ """args are: timestamp, channel, note, velocity.
+ consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
+ self.parentLibrary.parentData.instrumentMidiNoteOffActivity(self.idKey, pitch, velocity)
+
+
+
+ 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"Instrument was instructed to connect to port {externalPort}, which does not exist")
+ cbox.JackIO.port_connect(externalPort, self.cboxPortname)
def disable(self):
"""Jack midi port and audio ports will disappear. Impact on RAM and CPU usage unknown."""
diff --git a/engine/main.py b/engine/main.py
index bf9c771..21698d4 100644
--- a/engine/main.py
+++ b/engine/main.py
@@ -58,12 +58,12 @@ class Data(TemplateData):
def __init__(self, parentSession): #Program start.
super().__init__(parentSession)
session = self.parentSession #self.parentSession is already defined in template.data. We just want to work conveniently in init with it by creating a local var.
- self.libraries = {} # libraryId:int : Library-object
self._processAfterInit()
def _processAfterInit(self):
session = self.parentSession #We just want to work conveniently in init with it by creating a local var.
self.auditioner = None #set later.
+ self.libraries = {} # libraryId:int : Library-object
self.cachedSerializedDataForStartEngine = None
def allInstr(self):
@@ -71,7 +71,7 @@ class Data(TemplateData):
for instrId, instr in lib.instruments.items():
yield instr
- def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity):
+ def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity, instrumentMidiNoteOffActivity):
"""Called first by api.startEngine, which receives the global sample path from
the GUI.
@@ -168,6 +168,7 @@ class Data(TemplateData):
logger.error("There were no sample libraries to parse! This is correct on an empty run, since you still need to choose a sample directory.")
self.instrumentMidiNoteOnActivity = instrumentMidiNoteOnActivity # the api will inject a callback function here which takes (libId, instrId) as parameter to indicate midi noteOn activity for non-critical information like a GUI LED blinking or checking for new keyswitch states. The instruments individiual midiprocessor will call this as a parent-call.
+ self.instrumentMidiNoteOffActivity = instrumentMidiNoteOffActivity #see above
if firstRun: #in case of re-scan we don't need to do this a second time. The default lib cannot be updated through the download manager and will always be present.
self._createGlobalPorts() #in its own function for readability
self._createCachedJackMetadataSorting()
diff --git a/qtgui/designer/mainwindow.py b/qtgui/designer/mainwindow.py
index 75b66cb..ca5398f 100644
--- a/qtgui/designer/mainwindow.py
+++ b/qtgui/designer/mainwindow.py
@@ -14,24 +14,28 @@ from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
- MainWindow.resize(1087, 752)
+ MainWindow.resize(1364, 977)
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
- self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
- self.verticalLayout.setObjectName("verticalLayout")
- self.splitter_2 = QtWidgets.QSplitter(self.centralwidget)
- self.splitter_2.setOrientation(QtCore.Qt.Horizontal)
- self.splitter_2.setObjectName("splitter_2")
- self.search_groupBox = QtWidgets.QGroupBox(self.splitter_2)
- self.search_groupBox.setObjectName("search_groupBox")
- self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.search_groupBox)
+ self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self.centralwidget)
+ self.horizontalLayout_3.setObjectName("horizontalLayout_3")
+ self.verticalPianoFrame = QtWidgets.QFrame(self.centralwidget)
+ self.verticalPianoFrame.setMinimumSize(QtCore.QSize(150, 0))
+ self.verticalPianoFrame.setMaximumSize(QtCore.QSize(150, 16777215))
+ self.verticalPianoFrame.setFrameShape(QtWidgets.QFrame.NoFrame)
+ self.verticalPianoFrame.setFrameShadow(QtWidgets.QFrame.Plain)
+ self.verticalPianoFrame.setObjectName("verticalPianoFrame")
+ self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.verticalPianoFrame)
self.verticalLayout_4.setContentsMargins(0, 0, 0, 0)
self.verticalLayout_4.setSpacing(0)
self.verticalLayout_4.setObjectName("verticalLayout_4")
- self.search_listWidget = QtWidgets.QListWidget(self.search_groupBox)
- self.search_listWidget.setObjectName("search_listWidget")
- self.verticalLayout_4.addWidget(self.search_listWidget)
- self.splitter = QtWidgets.QSplitter(self.splitter_2)
+ self.horizontalLayout_3.addWidget(self.verticalPianoFrame)
+ self.rightFrame = QtWidgets.QWidget(self.centralwidget)
+ self.rightFrame.setObjectName("rightFrame")
+ self.verticalLayout = QtWidgets.QVBoxLayout(self.rightFrame)
+ self.verticalLayout.setContentsMargins(0, 0, 0, 0)
+ self.verticalLayout.setObjectName("verticalLayout")
+ self.splitter = QtWidgets.QSplitter(self.rightFrame)
self.splitter.setOrientation(QtCore.Qt.Vertical)
self.splitter.setObjectName("splitter")
self.iinstruments_tabWidget = QtWidgets.QTabWidget(self.splitter)
@@ -102,7 +106,7 @@ class Ui_MainWindow(object):
self.scrollArea.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.scrollArea.setObjectName("scrollArea")
self.mixerAreaWidget = QtWidgets.QWidget()
- self.mixerAreaWidget.setGeometry(QtCore.QRect(0, 0, 626, 452))
+ self.mixerAreaWidget.setGeometry(QtCore.QRect(0, 0, 1184, 510))
self.mixerAreaWidget.setObjectName("mixerAreaWidget")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.mixerAreaWidget)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
@@ -132,7 +136,7 @@ class Ui_MainWindow(object):
self.details_scrollArea.setWidgetResizable(True)
self.details_scrollArea.setObjectName("details_scrollArea")
self.scrollAreaWidgetContents = QtWidgets.QWidget()
- self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 624, 150))
+ self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 1182, 225))
self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
self.formLayout = QtWidgets.QFormLayout(self.scrollAreaWidgetContents)
self.formLayout.setObjectName("formLayout")
@@ -155,10 +159,17 @@ class Ui_MainWindow(object):
self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.keySwitch_label)
self.details_scrollArea.setWidget(self.scrollAreaWidgetContents)
self.verticalLayout_3.addWidget(self.details_scrollArea)
- self.verticalLayout.addWidget(self.splitter_2)
+ self.verticalLayout.addWidget(self.splitter)
+ self.horizontalPianoFrame = QtWidgets.QFrame(self.rightFrame)
+ self.horizontalPianoFrame.setMinimumSize(QtCore.QSize(0, 100))
+ self.horizontalPianoFrame.setFrameShape(QtWidgets.QFrame.StyledPanel)
+ self.horizontalPianoFrame.setFrameShadow(QtWidgets.QFrame.Raised)
+ self.horizontalPianoFrame.setObjectName("horizontalPianoFrame")
+ self.verticalLayout.addWidget(self.horizontalPianoFrame)
+ self.horizontalLayout_3.addWidget(self.rightFrame)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow)
- self.menubar.setGeometry(QtCore.QRect(0, 0, 1087, 20))
+ self.menubar.setGeometry(QtCore.QRect(0, 0, 1364, 20))
self.menubar.setObjectName("menubar")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(MainWindow)
@@ -172,7 +183,6 @@ class Ui_MainWindow(object):
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
- self.search_groupBox.setTitle(_translate("MainWindow", "Search"))
self.label.setText(_translate("MainWindow", "Auditioner MIDI Input"))
self.auditionerCurrentInstrument_label.setText(_translate("MainWindow", "TextLabel"))
self.instruments_treeWidget.headerItem().setText(1, _translate("MainWindow", "ID"))
diff --git a/qtgui/designer/mainwindow.ui b/qtgui/designer/mainwindow.ui
index 7a267d4..6bc02b1 100644
--- a/qtgui/designer/mainwindow.ui
+++ b/qtgui/designer/mainwindow.ui
@@ -6,379 +6,422 @@
0
0
- 1087
- 752
+ 1364
+ 977
MainWindow
-
+
-
-
-
- Qt::Horizontal
+
+
+
+ 150
+ 0
+
-
-
- Search
+
+
+ 150
+ 16777215
+
+
+
+ QFrame::NoFrame
+
+
+ QFrame::Plain
+
+
+
+ 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
-
-
-
-
-
-
-
- Qt::Vertical
+
+ 0
-
-
- true
-
-
- 0
-
-
-
- Instruments
-
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+
+
+ -
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ true
-
+
0
-
-
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- 3
- 0
-
-
-
- -40
-
-
- 0
-
-
- 3
-
-
- -3
-
-
- false
-
-
- 3.000000000000000
-
-
- true
+
+
+ Instruments
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+
-
+
+
+
+ 32
+ 32
+
+
+
+
+ 3
+ 0
+
+
+
+ -40
+
+
+ 0
+
+
+ 3
+
+
+ -3
+
+
+ false
+
+
+ 3.000000000000000
+
+
+ true
+
+
+
+ -
+
+
+ Auditioner MIDI Input
+
+
+
+ -
+
+
+ QComboBox::AdjustToContents
+
+
+
+ -
+
+
+ TextLabel
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+ -
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+ false
+
+
+ true
+
+
+ QAbstractItemView::SingleSelection
+
+
+ QAbstractItemView::ScrollPerPixel
+
+
+ QAbstractItemView::ScrollPerPixel
+
+
+
+
-
-
- -
-
+
+
- Auditioner MIDI Input
+ ID
-
-
- -
-
-
- QComboBox::AdjustToContents
+
+
+
+ Volume
-
-
- -
-
+
+
- TextLabel
+ Name
-
-
- -
-
-
- Qt::Horizontal
+
+
+
+ Variant
-
-
- 40
- 20
-
+
+
+
+ Tags
-
-
-
-
-
- -
-
-
- QAbstractItemView::NoEditTriggers
-
-
- false
+
+
+
+
+
+
+
+ Mixer
+
+
+
+ 0
-
- true
+
+ 0
-
- QAbstractItemView::SingleSelection
+
+ 0
-
- QAbstractItemView::ScrollPerPixel
+
+ 0
-
- QAbstractItemView::ScrollPerPixel
+
+ 0
-
-
-
-
-
-
-
- ID
-
-
-
-
- Volume
-
-
-
-
- Name
-
-
-
-
- Variant
-
-
-
-
- Tags
-
-
-
-
-
-
-
-
- Mixer
-
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
+ -
+
+
+
+ 0
+ 0
+
+
+
+ This mixer controls the amount of each loaded instrument in the optional mixer output-ports. Idividual instrument outputs are unaffected.
+
+
+ true
+
+
+
+ -
+
+
+ true
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+
+
+ 0
+ 0
+ 1184
+ 510
+
+
+
+
-
+
+
+ 24
+
+
+ Qt::Vertical
+
+
+ QProgressBar::TopToBottom
+
+
+
+ -
+
+
+ 24
+
+
+ Qt::Vertical
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NamePlaceholder
- -
-
-
-
- 0
- 0
-
-
-
- This mixer controls the amount of each loaded instrument in the optional mixer output-ports. Idividual instrument outputs are unaffected.
-
-
- true
-
-
-
- -
-
-
- true
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
-
-
-
-
- 0
- 0
- 626
- 452
-
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ true
-
-
-
-
-
- 24
-
-
- Qt::Vertical
-
-
- QProgressBar::TopToBottom
-
-
-
- -
-
-
- 24
-
-
- Qt::Vertical
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
+
+
+
+ 0
+ 0
+ 1182
+ 225
+
+
+
+ -
+
+
+ -
+
+
+ Variants
+
+
+
+ -
+
+
+ TextLabel
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+ true
+
+
+
+ -
+
+
+ -
+
+
+ KeySwitch
+
+
+
+
+
-
-
-
+
+
+
-
-
-
- NamePlaceholder
-
-
-
- 0
-
-
- 0
+
+ -
+
+
+
+ 0
+ 100
+
-
- 0
+
+ QFrame::StyledPanel
-
- 0
+
+ QFrame::Raised
-
- 0
-
-
-
-
-
- true
-
-
-
-
- 0
- 0
- 624
- 150
-
-
-
-
-
-
-
- -
-
-
- Variants
-
-
-
- -
-
-
- TextLabel
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
-
-
- true
-
-
-
- -
-
-
- -
-
-
- KeySwitch
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -388,7 +431,7 @@
0
0
- 1087
+ 1364
20
diff --git a/qtgui/instrument.py b/qtgui/instrument.py
index 6b8eafa..bc23f78 100644
--- a/qtgui/instrument.py
+++ b/qtgui/instrument.py
@@ -28,6 +28,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets
#Our Qt
from template.qtgui.helper import ToggleSwitch,FancySwitch
+from template.qtgui.submenus import nestedJackPortsMenu
#Engine
import engine.api as api
@@ -81,6 +82,7 @@ class InstrumentTreeController(object):
self.treeWidget.itemSelectionChanged.connect(self.itemSelectionChanged)
self.treeWidget.itemExpanded.connect(self.itemExpandedOrCollapsed)
self.treeWidget.itemCollapsed.connect(self.itemExpandedOrCollapsed)
+ self.treeWidget.customContextMenuRequested.connect(self.contextMenu)
self.sortByColumnValue = 1 #by instrId
self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
@@ -89,6 +91,7 @@ class InstrumentTreeController(object):
api.callbacks.startLoadingSamples.append(self.react_startLoadingSamples)
api.callbacks.instrumentStatusChanged.append(self.react_instrumentStatusChanged)
api.callbacks.instrumentMidiNoteOnActivity.append(self.react_instrumentMidiNoteOnActivity)
+ #api.callbacks.instrumentMidiNoteOffActivity.append(self.react_instrumentMidiNoteOffActivity) #We don't react to this here but switch off our indicator with a timer.
#self.treeWidget.setStyleSheet("QTreeWidget::item { border-bottom: 1px solid black;}") #sadly for all items. We want a line below top level items.
@@ -282,10 +285,23 @@ class InstrumentTreeController(object):
instr.setText(loadedIndex, text)
self.parentMainWindow.qtApp.processEvents() #actually show the label and cursor
- def react_instrumentMidiNoteOnActivity(self, idKey:tuple):
+ def react_instrumentMidiNoteOnActivity(self, idKey:tuple, pitch:int, velocity:int):
#First figure out which instrument has activity
gi = self.guiInstruments[idKey]
- gi.activity()
+ gi.activity() #We only do a quick flash here. No need for velocity, pitch or note-off
+
+
+ def contextMenu(self, qpoint):
+ item = self.treeWidget.itemAt(qpoint)
+ if not type(item) is GuiInstrument: #and not GuiLibrary
+ return
+ elif not item.state:
+ return
+ else:
+ externalPort = nestedJackPortsMenu() #returns None or clientAndPortString
+ if externalPort: #None or ClientPortString
+ api.connectInstrumentPort(item.idKey, externalPort)
+
class GuiLibrary(QtWidgets.QTreeWidgetItem):
@@ -334,8 +350,6 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem):
allItems = {} # instrId : GuiInstrument
-
-
def __init__(self, parentTreeController, instrumentDict):
GuiInstrument.allItems[instrumentDict["idKey"]] = self
self.parentTreeController = parentTreeController
diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py
index e2cd14a..c85f434 100644
--- a/qtgui/mainwindow.py
+++ b/qtgui/mainwindow.py
@@ -39,7 +39,7 @@ from engine.config import * #imports METADATA
from .instrument import InstrumentTreeController
from .auditioner import AuditionerMidiInputComboController
from .selectedinstrumentcontroller import SelectedInstrumentController
-
+from .verticalpiano import VerticalPiano
from .chooseDownloadDirectory import ChooseDownloadDirectory
class MainWindow(TemplateMainWindow):
@@ -67,10 +67,6 @@ class MainWindow(TemplateMainWindow):
super().__init__()
- #make the search bar smaller
- self.ui.search_groupBox.setMinimumSize(30, 1)
- self.ui.splitter_2.setSizes([1,1]) #just a forced update
-
#Make the description field at least a bit visible
self.ui.details_groupBox.setMinimumSize(1, 50)
self.ui.splitter.setSizes([1,1]) #just a forced update
@@ -83,12 +79,38 @@ class MainWindow(TemplateMainWindow):
self.tabWidget.setTabBarAutoHide(True)
self.tabWidget.setTabVisible(1, False) #Hide Mixer until we decide if we need it. #TODO
- self.ui.search_groupBox.setVisible(False) #Hide Search until we decide if we need it. #TODO
+ #Set up the two pianos
+ self.verticalPiano = VerticalPiano(self)
+ self.ui.verticalPianoFrame.layout().addWidget(self.verticalPiano) #add to DesignerUi. Layout is empty
+ #self.verticalPiano.setParent()
+ self.ui.verticalPianoFrame.setFixedWidth(150)
- self.setupMenu()
+ #self.horizontalPiano = HorizontalPiano(self)
+ #self.ui.horizontalPianoScrollArea.setWidget(self.horizontalPiano)
+
+
+ style = """
+ QScrollBar:horizontal {
+ border: 1px solid black;
+ }
+ QScrollBar::handle:horizontal {
+ background: #00b2b2;
+ }
+ QScrollBar:vertical {
+ border: 1px solid black;
+ }
+
+ QScrollBar::handle:vertical {
+ background: #00b2b2;
+ }
+
+ """
+ #self.setStyleSheet(style)
+
+ self.setupMenu()
#Find out if we already have a global sample directory
additionalData={}
@@ -119,7 +141,9 @@ class MainWindow(TemplateMainWindow):
else:
additionalData["baseSamplePath"] = "/tmp"
- print (additionalData)
+ if settings.contains("pianoRollVisible"):
+ self.pianoRollToggleVisibleAndRemember(settings.value("pianoRollVisible", type=bool))
+
self.start(additionalData) #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange.
api.callbacks.rescanSampleDir.append(self.react_rescanSampleDir) #This only happens on actual, manually instructed rescanning through the api. We instruct this through our Rescan-Dialog.
@@ -147,7 +171,8 @@ class MainWindow(TemplateMainWindow):
else:
nested = True
self.menu.addMenuEntry("menuView", "actionFlatNested", QtCore.QCoreApplication.translate("Menu", "Nested Instrument List"), self.instrumentTreeController.toggleNestedFlat, checkable=True, startChecked=nested) #function receives check state as automatic parameter
-
+ self.menu.addMenuEntry("menuView", "actionPianoRollVisible", QtCore.QCoreApplication.translate("Menu", "Piano Roll"), self.pianoRollToggleVisibleAndRemember, shortcut="Ctrl+R", checkable=True, startChecked=nested) #function receives check state as automatic parameter
+ self.menu.addMenuEntry("menuView", "actionPianoVisible", QtCore.QCoreApplication.translate("Menu", "Piano"), self.pianoToggleVisibleAndRemember, shortcut="Ctrl+P", checkable=True, startChecked=nested) #function receives check state as automatic parameter
self.menu.orderSubmenus(["menuFile", "menuEdit", "menuView", "menuHelp"])
@@ -160,7 +185,26 @@ class MainWindow(TemplateMainWindow):
self.instrumentTreeController.reset()
+ def pianoRollToggleVisibleAndRemember(self, state:bool):
+ self.ui.verticalPianoFrame.setVisible(state)
+ settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
+ settings.setValue("pianoRollVisible", state)
+ self.ui.actionPianoRollVisible.setChecked(state) #if called from outside the menu, e.g. load
+
+ def pianoToggleVisibleAndRemember(self, state:bool):
+ self.ui.horizontalPianoFrame.setVisible(state)
+ settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
+ settings.setValue("pianoVisible", state)
+ self.ui.actionPianoVisible.setChecked(state) #if called from outside the menu, e.g. load
+
+ def selectedInstrumentChanged(self, instrumentStatus, instrumentData):
+ """We receive this from selectedinstrumentcontroller.py, when the user clicks on a GUI
+ entry for a different instrument. This is purely a GUI function. This functions
+ relays the change to other widgets, except the above mentioned controller.
+ This only triggers for actual instruments, not a click on a library.
+ """
+ self.verticalPiano.pianoScene.selectedInstrumentChanged(instrumentStatus, instrumentData)
def zoom(self, scaleFactor:float):
pass
diff --git a/qtgui/selectedinstrumentcontroller.py b/qtgui/selectedinstrumentcontroller.py
index cc7b2c5..0e122e4 100644
--- a/qtgui/selectedinstrumentcontroller.py
+++ b/qtgui/selectedinstrumentcontroller.py
@@ -36,6 +36,10 @@ import engine.api as api
class SelectedInstrumentController(object):
"""Not a qt class. We externally control a collection of widgets.
There is only one set of widgets. We change their contents dynamically.
+
+ The engine has no concept of "selected instrument". This is purely on our GUI side.
+ We relay this information not only to our own information widgets but also to other widgets,
+ like the piano.
"""
def __init__(self, parentMainWindow):
@@ -130,6 +134,9 @@ class SelectedInstrumentController(object):
We combine static metadata, which we saved ourselves, with the current instrument status
(e.g. which variant was chosen).
+
+ We also relay this information to the main window which can send it to other widgets,
+ like the two keyboards. We will not receive this callback from the mainwindow.
"""
libraryId, instrumentId = idkey
self.currentIdKey = idkey
@@ -157,6 +164,9 @@ class SelectedInstrumentController(object):
#Dynamic
self.react_instrumentStatusChanged(self.statusUpdates[libraryId][instrumentId])
+ #Relay to Main Window. We will not receive this ourselves.
+ self.parentMainWindow.selectedInstrumentChanged(instrumentStatus, instrumentData)
+
def react_instrumentStatusChanged(self, instrumentStatus:dict):
"""Callback from the api. Has nothing to do with any GUI state or selection.
diff --git a/qtgui/verticalpiano.py b/qtgui/verticalpiano.py
new file mode 100644
index 0000000..0742d1f
--- /dev/null
+++ b/qtgui/verticalpiano.py
@@ -0,0 +1,330 @@
+#! /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 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; logger = logging.getLogger(__name__); logger.info("import")
+
+#Standard Library Modules
+
+#Third Party Modules
+from PyQt5 import QtWidgets, QtCore, QtGui, QtOpenGL
+
+#Template Modules
+from template.qtgui.helper import stretchRect
+from template.engine.duration import baseDurationToTraditionalNumber
+
+#User modules
+import engine.api as api
+
+
+MAX_DURATION = 200 #to keep the code copy/paste compatible with piano grid we use the same constant but use our own value
+STAFFLINEGAP = 20 #cannot be changed during runtime
+SCOREHEIGHT = STAFFLINEGAP * 128 #notes
+
+
+class VerticalPiano(QtWidgets.QGraphicsView):
+ def __init__(self, mainWindow):
+ super().__init__(mainWindow)
+ self.mainWindow = mainWindow
+
+ viewport = QtWidgets.QOpenGLWidget()
+ viewportFormat = QtGui.QSurfaceFormat()
+ viewportFormat.setSwapInterval(0) #disable VSync
+ #viewportFormat.setSamples(2**8) #By default, the highest number of samples available is used.
+ viewportFormat.setDefaultFormat(viewportFormat)
+ viewport.setFormat(viewportFormat)
+ self.setViewport(viewport)
+
+ self.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
+ self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
+
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ #self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
+
+ self.pianoScene = _VerticalPianoScene(self)
+ self.setScene(self.pianoScene)
+
+ self.setSceneRect(QtCore.QRectF(0, 0, MAX_DURATION/2, SCOREHEIGHT)) #x,y,w,h
+ #self.setFixedHeight(SCOREHEIGHT+3) # Don't set to scoreheight. Then we don't get a scrollbar. We need to set the sceneRect to 100%, not the view.
+ #self.mainWindow.ui.verticalPianoFrame.setFixedHeight(SCOREHEIGHT+3) #Don't. This makes the whole window a fixed size!
+ #self.setFixedWidth(MAX_DURATION) #Also done by parent widget in mainWindow
+
+ self.setLineWidth(0)
+
+ self.centerOn(0, 64*STAFFLINEGAP)
+
+
+class _VerticalPianoScene(QtWidgets.QGraphicsScene):
+ """Most of this is copy paste from piano grid"""
+
+ def __init__(self, parentView):
+ super().__init__()
+
+ self.parentView = parentView
+ #Set color, otherwise it will be transparent in window managers or wayland that want that.
+ self.backColor = QtGui.QColor()
+ self.backColor.setNamedColor("#fdfdff")
+ self.setBackgroundBrush(self.backColor)
+
+ self.linesHorizontal = []
+ self.highlights = {}
+ self.colorKeys = {}
+ self.blackKeys = []
+ self.numberLabels = [] #index is pitch
+
+
+ self._selectedInstrument = None #tuple instrumentStatus, instrumentData
+ self._leftMouseDown = False #For note preview
+
+ self.gridPen = QtGui.QPen(QtCore.Qt.SolidLine)
+ self.gridPen.setCosmetic(True)
+
+ #Create two lines for the upper/lower boundaries first. They are just cosmetic
+ boldPen = QtGui.QPen(QtCore.Qt.SolidLine)
+ boldPen.setCosmetic(True)
+ boldPen.setWidth(1)
+
+ hlineUp = QtWidgets.QGraphicsLineItem(0, 0, MAX_DURATION*2, 0) #x1, y1, x2, y2
+ hlineUp.setPen(boldPen)
+ self.addItem(hlineUp)
+ hlineUp.setPos(0, 0)
+ hlineUp.setEnabled(False)
+ hlineUp.setAcceptedMouseButtons(QtCore.Qt.NoButton)
+ hlineUp.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
+ self.linesHorizontal.append(hlineUp)
+
+ hlineDown = QtWidgets.QGraphicsLineItem(0, 0, MAX_DURATION*2, 0) #x1, y1, x2, y2
+ hlineDown.setPen(boldPen)
+ self.addItem(hlineDown)
+ hlineDown.setPos(0, 128 * STAFFLINEGAP)
+ hlineDown.setEnabled(False)
+ hlineDown.setAcceptedMouseButtons(QtCore.Qt.NoButton)
+ hlineDown.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
+ self.linesHorizontal.append(hlineDown)
+
+ for i in range(128):
+ hline = QtWidgets.QGraphicsLineItem(0, 0, MAX_DURATION*2, 0) #x1, y1, x2, y2
+ hline.setPen(self.gridPen)
+ self.addItem(hline)
+ hline.setPos(0, i * STAFFLINEGAP)
+ hline.setEnabled(False)
+ hline.setAcceptedMouseButtons(QtCore.Qt.NoButton)
+ self.linesHorizontal.append(hline)
+
+ blackKey = i % 12 in (1, 3, 6, 8, 10)
+ if blackKey:
+ bk = BlackKey(self)
+ self.blackKeys.append(bk)
+ self.addItem(bk)
+ bk.setPos(0, (127-i) * STAFFLINEGAP)
+
+ #Various purpose color keys. They are opaque and are on top of white/black keys
+ ck = ColorKey(self, QtGui.QColor("cyan"))
+ self.addItem(ck)
+ self.colorKeys[i] = ck
+ ck.setPos(0, (127-i) * STAFFLINEGAP)
+
+ #Highlights on top of colors. Indication if note is played.
+ hl = Highlight(self)
+ self.addItem(hl)
+ self.highlights[i] = hl
+ hl.setPos(0, (127-i) * STAFFLINEGAP)
+
+ #Numbers last so they are on top.
+ numberLabel = NumberLabel(self, 127-i)
+ self.addItem(numberLabel)
+ self.numberLabels.append(numberLabel) #index is pitch
+ numberLabel.setPos(0, i * STAFFLINEGAP + 2)
+ numberLabel.setZValue(10)
+
+ self.numberLabels.reverse()
+
+ self.fakeDeactivationOverlay = QtWidgets.QGraphicsRectItem(0,0,MAX_DURATION,SCOREHEIGHT)
+ self.fakeDeactivationOverlay.setBrush(QtGui.QColor("black"))
+ self.fakeDeactivationOverlay.setOpacity(0.6)
+ self.fakeDeactivationOverlay.setEnabled(False)
+ self.addItem(self.fakeDeactivationOverlay)
+ self.fakeDeactivationOverlay.setPos(0,0)
+ self.fakeDeactivationOverlay.show()
+ #Keyboard Creation Done
+
+ api.callbacks.instrumentMidiNoteOnActivity.append(self.highlightNoteOn)
+ api.callbacks.instrumentMidiNoteOffActivity.append(self.highlightNoteOff)
+ api.callbacks.instrumentStatusChanged.append(self.instrumentStatusChanged)
+
+ def clearVerticalPiano(self):
+ self.allHighlightsOff()
+ for nl in self.numberLabels:
+ nl.setLabel("") #reset to just number
+ self.fakeDeactivationOverlay.show()
+
+ def instrumentStatusChanged(self, instrumentStatus:dict):
+ """GUI callback. Data is live"""
+ #Is this for us?
+ if instrumentStatus and self._selectedInstrument and not instrumentStatus["idKey"] == self._selectedInstrument[0]["idKey"]:
+ return
+ #else:
+ # print ("not for us", instrumentStatus["idKey"])
+
+ self.clearVerticalPiano()
+ if not instrumentStatus["state"]:
+ self.fakeDeactivationOverlay.show()
+ return
+
+ self.fakeDeactivationOverlay.hide()
+
+ for keyPitch, keyObject in self.colorKeys.items():
+ #self.numberLabels[keyPitch].show()
+ keyObject.show()
+
+ if keyPitch in instrumentStatus["keySwitches"]:
+ opcode, keyswitchLabel = instrumentStatus["keySwitches"][keyPitch]
+ self.numberLabels[keyPitch].setLabel(keyswitchLabel)
+ keyObject.setBrush(QtGui.QColor("red"))
+ elif keyPitch in instrumentStatus["playableKeys"]:
+ keyObject.setBrush(QtGui.QColor("orange"))
+ else:
+ #self.numberLabels[keyPitch].hide()
+ keyObject.hide()
+
+
+
+ def selectedInstrumentChanged(self, instrumentStatus, instrumentData):
+ """GUI click to different instrument. The arguments are cached GUI data"""
+ self._selectedInstrument = (instrumentStatus, instrumentData)
+ self.instrumentStatusChanged(instrumentStatus)
+
+ def highlightNoteOn(self, idKey:tuple, pitch:int, velocity:int):
+ highlight = self.highlights[pitch]
+ highlight.show()
+
+ def highlightNoteOff(self, idKey:tuple, pitch:int, velocity:int):
+ highlight = self.highlights[pitch]
+ highlight.hide()
+
+ def allHighlightsOff(self):
+ for pitch, highlight in self.highlights.items():
+ highlight.hide()
+
+ def mousePressEvent(self, event):
+ self._leftMouseDown = False
+ if event.button() == QtCore.Qt.LeftButton:
+ self._leftMouseDown = True
+ self._lastPlayPitch = None #this must not be in _play, otherwise you can't move the mouse while pressed down
+ self._play(event)
+ super().mousePressEvent(event)
+
+ def _off(self):
+ if self._selectedInstrument and not self._lastPlayPitch is None:
+ status, data = self._selectedInstrument
+ libId, instrId = status["idKey"]
+ api.sendNoteOffToInstrument(status["idKey"], self._lastPlayPitch)
+ self._lastPlayPitch = None
+
+ def _play(self, event):
+ assert self._leftMouseDown
+
+ pitch = 127 - int(event.scenePos().y() / STAFFLINEGAP)
+ if pitch < 0 or pitch > 127:
+ pitch = None
+
+ if self._selectedInstrument and not pitch == self._lastPlayPitch:
+ #TODO: Play note on at a different instrument than note off? Possible?
+ status, data = self._selectedInstrument
+
+ if not self._lastPlayPitch is None:
+ #Force a note off that is currently playing but not under the cursor anymore
+ #User did some input tricks with keyboard and mouse combined etc.
+ api.sendNoteOffToInstrument(status["idKey"], self._lastPlayPitch)
+
+ if not pitch is None:
+ #This is the normal note-on click
+ api.sendNoteOnToInstrument(status["idKey"], pitch)
+
+ self._lastPlayPitch = pitch
+
+ def mouseMoveEvent(self, event):
+ """Event button is always 0 in a mouse move event"""
+ if self._leftMouseDown:
+ self._play(event)
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if self._leftMouseDown:
+ self._off()
+ self._leftMouseDown = False
+ if event.button() == QtCore.Qt.LeftButton:
+ self._lastPlayPitch = None
+ super().mouseReleaseEvent(event)
+
+
+class NumberLabel(QtWidgets.QGraphicsSimpleTextItem):
+
+ def __init__(self, parentGrid, number:int):
+
+ super().__init__()
+ self.parentGrid = parentGrid
+ self.number = number
+ self.currentLabel = ""
+ self.setText(str(number))
+ self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
+ self.setScale(1)
+ self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
+ blackKey = number % 12 in (1, 3, 6, 8, 10)
+ if blackKey:
+ self.setBrush(QtGui.QColor("white"))
+ else:
+ self.setBrush(QtGui.QColor("black"))
+
+ def setLabel(self, label:str):
+ """Each key can have an optional text label for keyswitches, percussion names etc.
+ Use with empty string to reset to just the midi pitch number."""
+ self.currentLabel = label
+ self.setText(f"{self.number} {label}")
+
+
+class Highlight(QtWidgets.QGraphicsRectItem):
+ def __init__(self, parentGrid):
+ super().__init__(0, 0, MAX_DURATION, STAFFLINEGAP) #x, y, w, h
+ self.setEnabled(False) #Not clickable, still visible.
+ self.setAcceptedMouseButtons(QtCore.Qt.NoButton) #we still need this otherwise no rubberband.
+ self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
+ self.setBrush(QtGui.QColor("cyan"))
+ self.setOpacity(0.5)
+ self.hide()
+
+
+class ColorKey(QtWidgets.QGraphicsRectItem):
+ def __init__(self, parentGrid, color:QtGui.QColor):
+ super().__init__(0, 0, MAX_DURATION, STAFFLINEGAP) #x, y, w, h
+ self.setEnabled(False) #Not clickable, still visible.
+ self.setAcceptedMouseButtons(QtCore.Qt.NoButton) #we still need this otherwise no rubberband.
+ self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
+ self.setBrush(color)
+ self.setOpacity(0.2) #just a tint
+ self.hide()
+
+class BlackKey(QtWidgets.QGraphicsRectItem):
+ def __init__(self, parentGrid):
+ super().__init__(0, 0, MAX_DURATION, STAFFLINEGAP) #x, y, w, h
+ self.parentGrid = parentGrid
+ self.setPen(QtGui.QPen(QtCore.Qt.NoPen))
+ self.setBrush(QtGui.QColor("black"))
+ self.setEnabled(False)
+ self.setAcceptedMouseButtons(QtCore.Qt.NoButton) #we still need this otherwise no rubberband.
diff --git a/template/qtgui/submenus.py b/template/qtgui/submenus.py
index ec85b86..608cda8 100644
--- a/template/qtgui/submenus.py
+++ b/template/qtgui/submenus.py
@@ -21,12 +21,14 @@ along with this program. If not, see .
import logging; logger = logging.getLogger(__name__); logger.info("import")
+from calfbox import cbox
from typing import Iterable, Callable, Tuple
from PyQt5 import QtCore, QtGui, QtWidgets
import engine.api as api
from qtgui.constantsAndConfigs import constantsAndConfigs #Client constantsAndConfigs!
+
"""
There are two types of submenus in this file. The majority is created in menu.py during start up.
Like Clef, KeySig etc. These don't need to ask for any dynamic values.
@@ -125,3 +127,64 @@ class ChooseOne(Submenu):
self.layout.addWidget(button)
button.clicked.connect(function)
button.clicked.connect(self.done)
+
+
+#TODO: This breaks backend/frontend division. We ask calfbox/jack directly.
+def nestedJackPortsMenu(parseOtherOutputs:bool=True, midi:bool=True):
+ """
+ This function queries JACK each time it is called. No cache.
+
+ The jack graph has a fixed depth of 1.
+ Each client gets a submenu.
+
+ The default shows all output midi ports of other programs.
+ If we are a sampler this enables a context menu on an instrument to connect another programs
+ sequencer port, or a hardware controller, to us.
+
+ set parseOtherOutputs to False to get other input ports instead. Use this if we are the sequencer
+ and click on a track to connect it to an external synth.
+
+ switch midi to False for Audio ports. Use this to connect our sample audio outputs (mono)
+ to external effects or DAW recording tracks.
+
+ ourClientPortName is the full jack name, such as:
+ Patroneo:BassC
+ """
+
+ menu = QtWidgets.QMenu()
+
+ 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))
+
+ hardwareMenu = menu.addMenu("Hardware")
+ for clientAndPortString in result["hardware"]:
+ action = hardwareMenu.addAction(clientAndPortString)
+ action.setData(clientAndPortString)
+
+ softwareClean = {} #clientName:portName
+ clientMenus = {} #clientName:QMenu
+ for fullString in result["software"]:
+
+ if "a2j" in fullString:
+ continue
+
+ client, port = fullString.split(":", 1)
+
+ if not client in softwareClean:
+ softwareClean[client] = list()
+ clientMenus[client] = menu.addMenu(client)
+
+ softwareClean[client].append(port)
+ action = clientMenus[client].addAction(port)
+ action.setData(fullString)
+
+
+ pos = QtGui.QCursor.pos()
+ pos.setY(pos.y() + 5)
+ result = menu.exec_(pos)
+ if result: #None or QAction
+ return result.data() #text is just the port. data is the full string, we set this ourselves above.