Sampled Instrument Player with static and monolithic design. All instruments are built-in.
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.

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