You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
350 lines
14 KiB
350 lines
14 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
|
|
|
|
This application is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import logging; logger = logging.getLogger(__name__); logger.info("import")
|
|
|
|
#Standard Library
|
|
|
|
#Third Party
|
|
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
|
|
|
|
|
|
|
|
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):
|
|
self.parentMainWindow = parentMainWindow
|
|
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"""
|
|
libraryId, instrumentId = idKey
|
|
self.currentIdKey = None
|
|
self.ui.details_scrollArea.show()
|
|
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()
|
|
|
|
metadata = self.engineData[libraryId]["library"]
|
|
|
|
self.ui.details_groupBox.setTitle(metadata["name"])
|
|
|
|
self.ui.info_label.setText(self._metadataToDescriptionLabel(metadata))
|
|
|
|
|
|
def _metadataToDescriptionLabel(self, metadata:dict)->str:
|
|
"""Can work with instruments and libraries alike"""
|
|
|
|
if "variants" in metadata: #this is an instrument
|
|
fullText = metadata["description"] + "\n\nVendor: " + metadata["vendor"] + "\n\nLicense: " + metadata["license"]
|
|
else:
|
|
fullText = metadata["description"] + "\n\nVendor: " + metadata["vendor"]
|
|
return fullText
|
|
|
|
def _newVariantChosen(self, index:int):
|
|
"""User chose a new variant through the combo box"""
|
|
assert self.ui.variants_comboBox.currentIndex() == index
|
|
assert self.currentIdKey
|
|
api.chooseVariantByIndex(self.currentIdKey, index)
|
|
|
|
def _newKeySwitchChosen(self, index:int):
|
|
"""User chose a new keyswitch through the combo box.
|
|
Answer comes back via react_instrumentStatusChanged"""
|
|
assert self.ui.keySwitch_comboBox.currentIndex() == index
|
|
assert self.currentIdKey
|
|
#Send back the midi pitch, not the index
|
|
api.setInstrumentKeySwitch(self.currentIdKey, self.ui.keySwitch_comboBox.itemData(index))
|
|
|
|
def _populateKeySwitchComboBox(self, instrumentStatus):
|
|
"""Convert engine format from callback to qt combobox string.
|
|
|
|
This is called when activating an instrument, switching the variant or simply
|
|
coming back to an already loaded instrument in the GUI.
|
|
|
|
This might come in from a midi callback when we are not currently selected.
|
|
We test if this message is really for us.
|
|
"""
|
|
#TODO: distinguish between momentary keyswitches sw_up sw_down and permanent sw_last. We only want sw_last as selection but the rest must be shown as info somewhere.
|
|
|
|
self.ui.keySwitch_comboBox.clear()
|
|
|
|
if not instrumentStatus or not "keySwitches" in instrumentStatus: #not all instruments have keyswitches
|
|
self.ui.keySwitch_comboBox.setEnabled(False)
|
|
return
|
|
|
|
engineKeySwitchDict = instrumentStatus["keySwitches"]
|
|
self.ui.keySwitch_comboBox.setEnabled(True)
|
|
for midiPitch, (opcode, label) in sorted(engineKeySwitchDict.items()):
|
|
self.ui.keySwitch_comboBox.addItem(f"[{midiPitch}]: {label}", userData=midiPitch)
|
|
|
|
#set current one, if any. If not the engine-dict is None and we set to an empty entry, even if there are keyswitches. which is fine.
|
|
curIdx = self.ui.keySwitch_comboBox.findData(instrumentStatus["currentKeySwitch"])
|
|
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
|
|
widget.currentItemChanged->mainWindow.currentTreeItemChanged. We set the currentItem
|
|
ourselves, so we need to block our signals to avoid recursion.
|
|
|
|
Only one item can be selected at a time.
|
|
|
|
The currentTreeItem we receive is not a global instance but from a widget different to ours.
|
|
We need to find our local version of the same instrument/library/idKey first.
|
|
"""
|
|
isLibrary = type(currentTreeItem) is GuiLibrary
|
|
idKey = currentTreeItem.idKey
|
|
|
|
if isLibrary:
|
|
self.directLibrary(idKey)
|
|
else:
|
|
self.instrumentChanged(idKey)
|
|
|
|
def instrumentChanged(self, idKey:tuple):
|
|
"""This is a GUI-internal function. The user selected a different instrument from
|
|
the list. Single click, arrow keys etc.
|
|
|
|
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
|
|
|
|
self.ui.details_scrollArea.show()
|
|
|
|
#Cached
|
|
instrumentStatus = self.statusUpdates[libraryId][instrumentId]
|
|
|
|
#Static
|
|
instrumentData = self.engineData[libraryId][instrumentId]
|
|
self.ui.details_groupBox.setTitle(instrumentData["name"])
|
|
|
|
self.ui.keySwitch_label.show()
|
|
self.ui.keySwitch_comboBox.show()
|
|
self._populateKeySwitchComboBox(instrumentStatus["keySwitches"]) #clears
|
|
|
|
self.ui.variant_label.show()
|
|
self.ui.variants_comboBox.show()
|
|
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
|
|
self.react_instrumentStatusChanged(self.statusUpdates[libraryId][instrumentId])
|
|
|
|
|
|
def react_instrumentStatusChanged(self, instrumentStatus:dict):
|
|
"""Callback from the api. Has nothing to do with any GUI state or selection.
|
|
|
|
Happens if the user loads a variant.
|
|
|
|
Data:
|
|
#Static ids
|
|
result["id"] = self.metadata["id"]
|
|
result["idKey"] = self.idKey #redundancy for convenience.
|
|
|
|
#Dynamic data
|
|
result["currentVariant"] = self.currentVariant # str
|
|
result["state"] = self.enabled #bool
|
|
|
|
|
|
It is possible that this callback comes in from a midi trigger, so we have no guarantee
|
|
that this matches the currently selected instrument in the GUI. We will cache the updated
|
|
status but check if this is our message before changing any GUI fields.
|
|
|
|
This callback is called again by the GUI directly when switching the instrument with a
|
|
mouseclick in instrumentChanged and the GUI will use the cached data.
|
|
"""
|
|
idKey = instrumentStatus["idKey"]
|
|
libraryId, instrumentId = idKey
|
|
|
|
if not libraryId in self.statusUpdates:
|
|
self.statusUpdates[libraryId] = {} #empty library. status dict
|
|
self.statusUpdates[libraryId][instrumentId] = instrumentStatus #create or overwrite / keep up to date
|
|
|
|
if not self.currentIdKey == idKey:
|
|
#Callback for an instrument currently not selected
|
|
return
|
|
|
|
loadState = instrumentStatus["state"]
|
|
|
|
instrumentData = self.engineData[libraryId][instrumentId]
|
|
instrumentStatus = self.statusUpdates[libraryId][instrumentId]
|
|
if loadState: #None if not loaded
|
|
self.ui.variants_comboBox.setEnabled(True)
|
|
currentVariantIndex = instrumentData["variantsWithoutSfzExtension"].index(instrumentStatus["currentVariantWithoutSfzExtension"])
|
|
self.ui.variants_comboBox.setCurrentIndex(currentVariantIndex)
|
|
else:
|
|
self.ui.variants_comboBox.setEnabled(False)
|
|
defaultVariantIndex = instrumentData["variantsWithoutSfzExtension"].index(instrumentData["defaultVariantWithoutSfzExtension"])
|
|
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()
|
|
Summary:
|
|
libraryid : dict ->
|
|
instrumentid : metadatadict
|
|
|
|
Dict-Keys are always the same. Some always have data, some can be empty.
|
|
|
|
We receive this once at program start and build our permanent GUI widgets from it.
|
|
The additional status update callback for dynamic data is handled in
|
|
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)
|
|
|