Browse Source

First draft of a gui midi keyboard

master
Nils 2 years ago
parent
commit
903a20f51b
  1. 43
      engine/api.py
  2. 104
      engine/instrument.py
  3. 5
      engine/main.py
  4. 46
      qtgui/designer/mainwindow.py
  5. 725
      qtgui/designer/mainwindow.ui
  6. 22
      qtgui/instrument.py
  7. 62
      qtgui/mainwindow.py
  8. 10
      qtgui/selectedinstrumentcontroller.py
  9. 330
      qtgui/verticalpiano.py
  10. 63
      template/qtgui/submenus.py

43
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)

104
engine/instrument.py

@ -23,6 +23,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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."""

5
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()

46
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"))

725
qtgui/designer/mainwindow.ui

@ -6,379 +6,422 @@
<rect>
<x>0</x>
<y>0</y>
<width>1087</width>
<height>752</height>
<width>1364</width>
<height>977</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QSplitter" name="splitter_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<widget class="QFrame" name="verticalPianoFrame">
<property name="minimumSize">
<size>
<width>150</width>
<height>0</height>
</size>
</property>
<widget class="QGroupBox" name="search_groupBox">
<property name="title">
<string>Search</string>
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QListWidget" name="search_listWidget"/>
</item>
</layout>
</widget>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
<property name="leftMargin">
<number>0</number>
</property>
<widget class="QTabWidget" name="iinstruments_tabWidget">
<property name="enabled">
<bool>true</bool>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="Instruments">
<attribute name="title">
<string>Instruments</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="rightFrame" native="true">
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QTabWidget" name="iinstruments_tabWidget">
<property name="enabled">
<bool>true</bool>
</property>
<property name="bottomMargin">
<property name="currentIndex">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="auditionerWidget" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QDial" name="auditionerVolumeDial">
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="sizeIncrement">
<size>
<width>3</width>
<height>0</height>
</size>
</property>
<property name="minimum">
<number>-40</number>
</property>
<property name="maximum">
<number>0</number>
</property>
<property name="pageStep">
<number>3</number>
</property>
<property name="value">
<number>-3</number>
</property>
<property name="wrapping">
<bool>false</bool>
</property>
<property name="notchTarget">
<double>3.000000000000000</double>
</property>
<property name="notchesVisible">
<bool>true</bool>
<widget class="QWidget" name="Instruments">
<attribute name="title">
<string>Instruments</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="auditionerWidget" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QDial" name="auditionerVolumeDial">
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="sizeIncrement">
<size>
<width>3</width>
<height>0</height>
</size>
</property>
<property name="minimum">
<number>-40</number>
</property>
<property name="maximum">
<number>0</number>
</property>
<property name="pageStep">
<number>3</number>
</property>
<property name="value">
<number>-3</number>
</property>
<property name="wrapping">
<bool>false</bool>
</property>
<property name="notchTarget">
<double>3.000000000000000</double>
</property>
<property name="notchesVisible">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Auditioner MIDI Input</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="auditionerMidiInputComboBox">
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="auditionerCurrentInstrument_label">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="instruments_treeWidget">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="horizontalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<column>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
</column>
<column>
<property name="text">
<string>Auditioner MIDI Input</string>
<string>ID</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="auditionerMidiInputComboBox">
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</column>
<column>
<property name="text">
<string>Volume</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="auditionerCurrentInstrument_label">
</column>
<column>
<property name="text">
<string>TextLabel</string>
<string>Name</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</column>
<column>
<property name="text">
<string>Variant</string>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</column>
<column>
<property name="text">
<string>Tags</string>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="instruments_treeWidget">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="Mixer">
<attribute name="title">
<string>Mixer</string>
</attribute>
<layout class="QVBoxLayout" name="mixerVerticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
<property name="leftMargin">
<number>0</number>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
<property name="topMargin">
<number>0</number>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
<property name="rightMargin">
<number>0</number>
</property>
<property name="horizontalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
<property name="bottomMargin">
<number>0</number>
</property>
<column>
<property name="text">
<string/>
</property>
</column>
<column>
<property name="text">
<string>ID</string>
</property>
</column>
<column>
<property name="text">
<string>Volume</string>
</property>
</column>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Variant</string>
</property>
</column>
<column>
<property name="text">
<string>Tags</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="Mixer">
<attribute name="title">
<string>Mixer</string>
</attribute>
<layout class="QVBoxLayout" name="mixerVerticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
<item>
<widget class="QLabel" name="mixerInstructionLabel">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>This mixer controls the amount of each loaded instrument in the optional mixer output-ports. Idividual instrument outputs are unaffected.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<widget class="QWidget" name="mixerAreaWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1184</width>
<height>510</height>
</rect>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>24</number>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="textDirection">
<enum>QProgressBar::TopToBottom</enum>
</property>
</widget>
</item>
<item>
<widget class="QProgressBar" name="progressBar_2">
<property name="value">
<number>24</number>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QGroupBox" name="details_groupBox">
<property name="title">
<string>NamePlaceholder</string>
</property>
<item>
<widget class="QLabel" name="mixerInstructionLabel">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>This mixer controls the amount of each loaded instrument in the optional mixer output-ports. Idividual instrument outputs are unaffected.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<widget class="QWidget" name="mixerAreaWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>626</width>
<height>452</height>
</rect>
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QScrollArea" name="details_scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>24</number>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="textDirection">
<enum>QProgressBar::TopToBottom</enum>
</property>
</widget>
</item>
<item>
<widget class="QProgressBar" name="progressBar_2">
<property name="value">
<number>24</number>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
<widget class="QWidget" name="scrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1182</width>
<height>225</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="1" column="1">
<widget class="QComboBox" name="variants_comboBox"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="variant_label">
<property name="text">
<string>Variants</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="info_label">
<property name="text">
<string>TextLabel</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="keySwitch_comboBox"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="keySwitch_label">
<property name="text">
<string>KeySwitch</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</widget>
<widget class="QGroupBox" name="details_groupBox">
<property name="title">
<string>NamePlaceholder</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</item>
<item>
<widget class="QFrame" name="horizontalPianoFrame">
<property name="minimumSize">
<size>
<width>0</width>
<height>100</height>
</size>
</property>
<property name="topMargin">
<number>0</number>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="rightMargin">
<number>0</number>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QScrollArea" name="details_scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="scrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>624</width>
<height>150</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="1" column="1">
<widget class="QComboBox" name="variants_comboBox"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="variant_label">
<property name="text">
<string>Variants</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="info_label">
<property name="text">
<string>TextLabel</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="keySwitch_comboBox"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="keySwitch_label">
<property name="text">
<string>KeySwitch</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
@ -388,7 +431,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>1087</width>
<width>1364</width>
<height>20</height>
</rect>
</property>

22
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

62
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

10
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.

330
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 <http://www.gnu.org/licenses/>.
"""
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.

63
template/qtgui/submenus.py

@ -21,12 +21,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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.

Loading…
Cancel
Save