From b72bd48ba20e23628ef64500bc9688d559e43aad Mon Sep 17 00:00:00 2001 From: Nils <> Date: Sun, 10 Apr 2022 03:57:27 +0200 Subject: [PATCH] Show control change labels and make them gui midi controls. Expand default instrument to show off the feature --- README.md | 2 +- documentation/out/english.html | 2 +- documentation/out/german.html | 2 +- engine/api.py | 27 ++++- engine/instrument.py | 7 +- engine/main.py | 3 +- engine/resources/000 - Default.tembro | Bin 20480 -> 20480 bytes qtgui/designer/mainwindow.py | 66 ++++++++--- qtgui/designer/mainwindow.ui | 116 +++++++++++++------ qtgui/selectedinstrumentcontroller.py | 102 +++++++++++++++++ template/qtgui/flowlayout.py | 159 ++++++++++++++++++++++++++ 11 files changed, 429 insertions(+), 57 deletions(-) create mode 100644 template/qtgui/flowlayout.py diff --git a/README.md b/README.md index 857535f..3cf9f88 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[//]: # (Generated 2022-04-08T17:30:22.829359. Changes belong into template/documentation/readme.template) +[//]: # (Generated 2022-04-08T17:43:00.491140. Changes belong into template/documentation/readme.template) # Tembro diff --git a/documentation/out/english.html b/documentation/out/english.html index 83ad32a..f335a5a 100644 --- a/documentation/out/english.html +++ b/documentation/out/english.html @@ -717,7 +717,7 @@ The program is split in two parts. A shared "template" between the Laborejo Soft diff --git a/documentation/out/german.html b/documentation/out/german.html index 4997e95..be181e5 100644 --- a/documentation/out/german.html +++ b/documentation/out/german.html @@ -711,7 +711,7 @@ Ansonsten starten Sie tembro mit diesem Befehl, Sprachcode ändern, vom Terminal diff --git a/engine/api.py b/engine/api.py index f03619c..16f1783 100644 --- a/engine/api.py +++ b/engine/api.py @@ -51,6 +51,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks self.auditionerVolumeChanged = [] self.instrumentMidiNoteOnActivity = [] self.instrumentMidiNoteOffActivity = [] + self.instrumentCCActivity = [] def _tempCallback(self): """Just for copy paste during development""" @@ -133,6 +134,10 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks for func in self.instrumentMidiNoteOffActivity: func(idKey, pitch, velocity) + def _instrumentCCActivity(self, idKey, ccNumber, value): + for func in self.instrumentCCActivity: + func(idKey, ccNumber, value) + #Inject our derived Callbacks into the parent module template.engine.api.callbacks = ClientCallbacks() from template.engine.api import callbacks @@ -140,7 +145,7 @@ from template.engine.api import callbacks _templateStartEngine = startEngine def startEngine(nsmClient, additionalData): - session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity, callbacks._instrumentMidiNoteOffActivity ) + session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity, callbacks._instrumentMidiNoteOffActivity, callbacks._instrumentCCActivity ) _templateStartEngine(nsmClient) #loads save files or creates empty structure. @@ -193,7 +198,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.instrumentMidiNoteOffActivity) + session.data.parseAndLoadInstrumentLibraries(newBaseSamplePath, session.data.instrumentMidiNoteOnActivity, session.data.instrumentMidiNoteOffActivity, session.data.instrumentCCActivity ) callbacks._rescanSampleDir() #instructs the GUI to forget all cached data and start fresh. callbacks._instrumentListMetadata() #The big "build the database" callback @@ -364,3 +369,21 @@ def sendNoteOffToInstrument(idKey:tuple, midipitch:int): if instrument.enabled: instrument.scene.send_midi_event(0x80, midipitch, 0) callbacks._instrumentMidiNoteOffActivity(idKey, midipitch, 0) + + +def ccTrackingState(idKey:tuple): + """Get the current values of all CCs that have been modified through midi-in so far. + This also includes values we changed ourselves through sentCCToInstrument""" + instrument = _instr(idKey) + if instrument.enabled: + return instrument.midiProcessor.ccState + else: + return None + +def sentCCToInstrument(idKey:tuple, ccNumber:int, value:int): + instrument = _instr(idKey) + if instrument.enabled: + instrument.scene.send_midi_event(0xB0, ccNumber, value) + instrument.midiProcessor.ccState[ccNumber] = value #midi processor doesn't get send_midi_event + else: + return None diff --git a/engine/instrument.py b/engine/instrument.py index 6356486..fb7707c 100644 --- a/engine/instrument.py +++ b/engine/instrument.py @@ -384,6 +384,7 @@ class Instrument(object): self.playableKeys = tuple(sorted(allKeys)) self.controlLabels = self.program.get_control_labels() #opcode label_cc# in + self.keyLabels = self.program.get_key_labels() #opcode label_cc# in #Add some defaults. for k,v in {60:"Middle C", 53:"𝄢", 67:"𝄞"}.items(): @@ -504,6 +505,7 @@ class Instrument(object): self.midiProcessor = MidiProcessor(parentInput = self) #works through self.cboxMidiPortUid self.midiProcessor.register_NoteOn(self.triggerNoteOnCallback) self.midiProcessor.register_NoteOff(self.triggerNoteOffCallback) + self.midiProcessor.register_CC(self.triggerCCCallback) #self.midiProcessor.notePrinter(True) self.parentLibrary.parentData.parentSession.eventLoop.fastConnect(self.midiProcessor.processEvents) @@ -560,7 +562,10 @@ class Instrument(object): consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()""" self.parentLibrary.parentData.instrumentMidiNoteOffActivity(self.idKey, pitch, velocity) - + def triggerCCCallback(self, timestamp, channel, ccNumber, value): + """args are: timestamp, channel, ccNumber, value + consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()""" + self.parentLibrary.parentData.instrumentCCActivity(self.idKey, ccNumber, value) def getAvailablePorts(self)->dict: """This function queries JACK each time it is called. diff --git a/engine/main.py b/engine/main.py index 338c069..08e29cc 100644 --- a/engine/main.py +++ b/engine/main.py @@ -69,7 +69,7 @@ class Data(TemplateData): for instrId, instr in lib.instruments.items(): yield instr - def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity, instrumentMidiNoteOffActivity): + def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity, instrumentMidiNoteOffActivity, instrumentCCActivity): """Called first by api.startEngine, which receives the global sample path from the GUI. @@ -170,6 +170,7 @@ class Data(TemplateData): 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 + self.instrumentCCActivity = instrumentCCActivity #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/engine/resources/000 - Default.tembro b/engine/resources/000 - Default.tembro index 5ac70c656598beb2de0ccc3150e8c7a32a200fa0..3d1551bdbf5cea5002709b787eac7aeb1c866cd1 100644 GIT binary patch literal 20480 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 a1X>BS5@;pRN}!cMD}h!5tpsi%f&T)S(lq=4 delta 403 zcmY+=y-ve05Ww+VRWYyt%0wn3AylALXU881W#I{U0|ZhnU?8bd*ejI?XTO_8= zegU3=ujj#+Ry5sz{@G!CWE zVT`14snW}F?+`>vBr>>J#{Q_#(FOKnJFc(hn 0 0 - 1175 - 651 + 1003 + 668 @@ -320,6 +320,18 @@ + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + QAbstractScrollArea::AdjustToContents + true @@ -328,41 +340,81 @@ 0 0 - 993 - 125 + 823 + 136 - - - - - - - - Variants + + + + + + 0 + 1 + + + + QLayout::SetMinAndMaxSize + + + 20 + + + 15 + + + + + Variants + + + + + + + + + + KeySwitch + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + - - - - TextLabel + + + + + 0 + 1 + - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - - - - - - - - - - KeySwitch + + Controls @@ -401,7 +453,7 @@ 0 0 - 1175 + 1003 20 diff --git a/qtgui/selectedinstrumentcontroller.py b/qtgui/selectedinstrumentcontroller.py index 1fe32f7..ba6ab59 100644 --- a/qtgui/selectedinstrumentcontroller.py +++ b/qtgui/selectedinstrumentcontroller.py @@ -28,6 +28,8 @@ from PyQt5 import QtCore, QtGui, QtWidgets #Our Qt from .instrument import GuiInstrument, GuiLibrary #for the types +from template.qtgui.flowlayout import FlowLayout + #Engine import engine.api as api @@ -48,18 +50,23 @@ class SelectedInstrumentController(object): self.currentIdKey = None self.engineData = {} # idKey tuple : engine library metadata dict. self.statusUpdates = {} # same as engineData, but with incremental status updates. One is guranteed to exist at startup + self.controlWidgets = {} # ccNumber or other identifier : ControlWidget (our class from this file) #Our Widgets self.ui = parentMainWindow.ui self.ui.details_groupBox.setTitle("") self.ui.details_scrollArea.hide() #until the first instrument was selected + self.ui.controls_groupBox.setLayout(FlowLayout(margin=1)) self.ui.variants_comboBox.activated.connect(self._newVariantChosen) self.ui.keySwitch_comboBox.activated.connect(self._newKeySwitchChosen) + #Callbacks api.callbacks.instrumentListMetadata.append(self.react_initialInstrumentList) api.callbacks.instrumentStatusChanged.append(self.react_instrumentStatusChanged) + api.callbacks.instrumentCCActivity.append(self.react_instrumentCCActivity) + def directLibrary(self, idKey:tuple): """User clicked on a library treeItem""" @@ -69,6 +76,8 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.hide() self.ui.variant_label.hide() + self.ui.controls_groupBox.hide() + self.ui.keySwitch_label.hide() self.ui.keySwitch_comboBox.hide() @@ -129,6 +138,33 @@ class SelectedInstrumentController(object): self.ui.keySwitch_comboBox.setCurrentIndex(curIdx) + def _populateControls(self, instrumentStatus:dict): + """Remove all CC Knobs, Faders and other control widgets and draw them again for + the current GUI-Instrument. + + This is called when activating an instrument, switching the variant or simply + coming back to an already loaded instrument in the GUI.""" + + for controlWidget in self.controlWidgets.values(): + self.ui.controls_groupBox.layout().removeWidget(controlWidget) + controlWidget.hide() + controlWidget.setParent(None) + del controlWidget + self.controlWidgets = {} + + if not instrumentStatus["state"] or not instrumentStatus["controlLabels"] : #Not activated yet. + return + + ccNow = api.ccTrackingState(instrumentStatus["idKey"]) + + for ccNumber, ccLabel in instrumentStatus["controlLabels"].items(): + w = ControlWidget(self.ui.controls_groupBox, instrumentStatus, ccNumber, ccLabel) + self.controlWidgets[ccNumber] = w + self.ui.controls_groupBox.layout().addWidget(w) + if ccNumber in ccNow: + w.setValue(ccNow[ccNumber], sendToEngine=False) #don't send the engine values it already knows. + + def currentTreeItemChanged(self, currentTreeItem:QtWidgets.QTreeWidgetItem): """ Program wide GUI-only callback from @@ -179,6 +215,9 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.clear() self.ui.variants_comboBox.addItems(instrumentData["variantsWithoutSfzExtension"]) + self.ui.controls_groupBox.show() + self._populateControls(instrumentStatus) + self.ui.info_label.setText(self._metadataToDescriptionLabel(instrumentData)) #Dynamic @@ -232,6 +271,7 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.setCurrentIndex(defaultVariantIndex) self._populateKeySwitchComboBox(instrumentStatus) + self._populateControls(instrumentStatus) def react_initialInstrumentList(self, data:dict): """For data form see docstring of instrument.py buildTree() @@ -246,3 +286,65 @@ class SelectedInstrumentController(object): self.react_instrumentStatusChanged """ self.engineData = data + + def react_instrumentCCActivity(self, idKey, ccNumber, value): + if not idKey == self.currentIdKey: + return #not for us + + if ccNumber in self.controlWidgets: + self.controlWidgets[ccNumber].setValue(value) + + +class ControlWidget(QtWidgets.QWidget): + + def __init__(self, parentWidget, instrumentStatus, ccNumber, ccLabel): + super().__init__(parentWidget) + + self.instrumentStatus = instrumentStatus #static info about the complete instrument. + assert self.instrumentStatus["state"], instrumentStatus + assert self.instrumentStatus["controlLabels"], instrumentStatus + + self.idKey = instrumentStatus["idKey"] + self.ccNumber = ccNumber + + self.setLayout(QtWidgets.QVBoxLayout()) + + self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.slider.setTracking(True) + self.slider.valueChanged.connect(lambda v: self.setValue(v, sendToEngine=True)) + self.layout().addWidget(self.slider) + + self.spinBox = QtWidgets.QSpinBox() + self.spinBox.valueChanged.connect(lambda v: self.setValue(v, sendToEngine=True)) + self.spinBox.setPrefix(f"[{ccNumber}] ") + self.layout().addWidget(self.spinBox) + + self.label = QtWidgets.QLabel(ccLabel) + self.layout().addWidget(self.label) + + self.setFixedSize(132,96) + + self.setRange(0,127) + + #self.setValue(0) #No need. We don't want to destroy the instruments defaults. + self.spinBox.setSpecialValueText(f"[CC {ccNumber}]") #Never touched by human hands + + def setRange(self, floor:int, ceiling:int): + self.spinBox.setRange(floor, ceiling) + self.slider.setRange(floor, ceiling) + + def setValue(self, value:int, sendToEngine=False): + self.spinBox.blockSignals(True) + self.slider.blockSignals(True) + + self.spinBox.setSpecialValueText("") #0 is 0 + + self.spinBox.setValue(value) + self.slider.setValue(value) + + #Send to Engine + if sendToEngine: + api.sentCCToInstrument(self.idKey, self.ccNumber, value) + + self.spinBox.blockSignals(False) + self.slider.blockSignals(False) diff --git a/template/qtgui/flowlayout.py b/template/qtgui/flowlayout.py new file mode 100644 index 0000000..5173a49 --- /dev/null +++ b/template/qtgui/flowlayout.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python + + +############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# + + +from PyQt5.QtCore import QPoint, QRect, QSize, Qt +from PyQt5.QtWidgets import (QApplication, QLayout, QPushButton, QSizePolicy, + QWidget) + + +class Window(QWidget): + def __init__(self): + super(Window, self).__init__() + + flowLayout = FlowLayout() + flowLayout.addWidget(QPushButton("Short")) + flowLayout.addWidget(QPushButton("Longer")) + flowLayout.addWidget(QPushButton("Different text")) + flowLayout.addWidget(QPushButton("More text")) + flowLayout.addWidget(QPushButton("Even longer button text")) + self.setLayout(flowLayout) + + self.setWindowTitle("Flow Layout") + + +class FlowLayout(QLayout): + def __init__(self, parent=None, margin=0, spacing=-1): + super(FlowLayout, self).__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + + self.itemList = [] + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self.itemList.append(item) + + def count(self): + return len(self.itemList) + + def itemAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList[index] + + return None + + def takeAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList.pop(index) + + return None + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + height = self.doLayout(QRect(0, 0, width, 0), True) + return height + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self.doLayout(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self.itemList: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QSize(2 * margin, 2 * margin) + return size + + def doLayout(self, rect, testOnly): + x = rect.x() + y = rect.y() + lineHeight = 0 + + for item in self.itemList: + wid = item.widget() + spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal) + spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical) + nextX = x + item.sizeHint().width() + spaceX + if nextX - spaceX > rect.right() and lineHeight > 0: + x = rect.x() + y = y + lineHeight + spaceY + nextX = x + item.sizeHint().width() + spaceX + lineHeight = 0 + + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = nextX + lineHeight = max(lineHeight, item.sizeHint().height()) + + return y + lineHeight - rect.y() + + +if __name__ == '__main__': + + import sys + + app = QApplication(sys.argv) + mainWin = Window() + mainWin.show() + sys.exit(app.exec_())