diff --git a/engine/api.py b/engine/api.py index b83e222..b008b46 100644 --- a/engine/api.py +++ b/engine/api.py @@ -137,6 +137,8 @@ def startEngine(nsmClient, additionalData): #Inject the _instrumentMidiNoteOnActivity callback into session.data for access. session.data.instrumentMidiNoteOnActivity = callbacks._instrumentMidiNoteOnActivity + callbacks.instrumentMidiNoteOnActivity.append(_checkForKeySwitch) + callbacks._instrumentListMetadata() #Relatively quick. Happens only once in the program #One round of status updates for all instruments. Still no samples loaded, but we saved the status of each with our own data. for instrument in Instrument.allInstruments.values(): @@ -147,7 +149,6 @@ def startEngine(nsmClient, additionalData): atexit.register(unloadAllInstrumentSamples) #this will handle all python exceptions, but not segfaults of C modules. logger.info("Tembro api startEngine complete") - def loadAllInstrumentSamples(): """Actually load all instrument samples""" logger.info(f"Loading all instruments.") @@ -208,14 +209,34 @@ def setInstrumentMixerVolume(idkey:tuple, value:float): Default is -3.0 """ instrument = Instrument.allInstruments[idkey] instrument.mixerLevel = value - libraryId, instrumentId = idkey - callbacks._instrumentStatusChanged(libraryId, instrumentId) + callbacks._instrumentStatusChanged(*instrument.idKey) def setInstrumentMixerEnabled(idkey:tuple, state:bool): instrument = Instrument.allInstruments[idkey] instrument.setMixerEnabled(state) - libraryId, instrumentId = idkey - callbacks._instrumentStatusChanged(libraryId, instrumentId) + callbacks._instrumentStatusChanged(*instrument.idKey) + +def setInstrumentKeySwitch(idkey:tuple, keySwitchMidiPitch:int): + """Choose a keyswitch of the currently selected variant of this instrument. Keyswitch does not + exist: The engine will throw a warning if the keyswitch was not in our internal representation, + but nothing bad will happen otherwise. + + We use the key-midi-pitch directly. + """ + instrument = Instrument.allInstruments[idkey] + result = instrument.setKeySwitch(keySwitchMidiPitch) + if result: #could be None + changed, nowKeySwitchPitch = result + #Send in any case, no matter if changed or not. Doesn't hurt. + callbacks._instrumentStatusChanged(*instrument.idKey) + +def _checkForKeySwitch(idkey:tuple): + """We added this ourselves to the note-on midi callback. + So this gets called for every note-one.""" + instrument = Instrument.allInstruments[idkey] + changed, nowKeySwitchPitch = instrument.updateCurrentKeySwitch() + if changed: + callbacks._instrumentStatusChanged(*instrument.idKey) def auditionerInstrument(idkey:tuple): """Load an indendepent instance of an instrument into the auditioner port""" @@ -227,7 +248,8 @@ def auditionerInstrument(idkey:tuple): var = originalInstrument.currentVariant else: var = originalInstrument.defaultVariant - session.data.auditioner.loadInstrument(originalInstrument.tarFilePath, originalInstrument.rootPrefixPath, var) + + session.data.auditioner.loadInstrument(originalInstrument.tarFilePath, originalInstrument.rootPrefixPath, var, originalInstrument.currentKeySwitch) callbacks._auditionerInstrumentChanged(libraryId, instrumentId) def getAvailableAuditionerPorts()->dict: diff --git a/engine/auditioner.py b/engine/auditioner.py index 4da2570..859fb27 100644 --- a/engine/auditioner.py +++ b/engine/auditioner.py @@ -101,10 +101,11 @@ class Auditioner(object): #Dynamic data result["currentVariant"] = self.currentVariant # str result["state"] = self.enabled #bool + result["currentKeySwitch"] = self.currentKeySwitch #int or None return result - def loadInstrument(self, tarFilePath, rootPrefixPath:str, variantSfzFileName:str): + def loadInstrument(self, tarFilePath, rootPrefixPath:str, variantSfzFileName:str, keySwitchMidiPitch:int): """load_patch_from_tar is blocking. This function will return when the instrument is ready to play. @@ -113,11 +114,17 @@ class Auditioner(object): logger.info(f"Start loading samples for auditioner {variantSfzFileName}") #help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc. #newProgramNumber = self.instrumentLayer.engine.get_unused_program() - programNumber = 1 + programNumber = 0 name = variantSfzFileName self.program = self.instrumentLayer.engine.load_patch_from_tar(programNumber, tarFilePath, rootPrefixPath+variantSfzFileName, name) - self.instrumentLayer.engine.set_patch(1, programNumber) #1 is the channel, counting from 1. #TODO: we want this to be on all channels. self.currentVariant = variantSfzFileName + + if keySwitchMidiPitch is None: + self.currentKeySwitch = None + else: + self.currentKeySwitch = keySwitchMidiPitch + self.scene.send_midi_event(0x90, keySwitchMidiPitch, 64) + logger.info(f"Finished loading samples for auditioner {variantSfzFileName}") diff --git a/engine/instrument.py b/engine/instrument.py index 7b52b59..c29a82c 100644 --- a/engine/instrument.py +++ b/engine/instrument.py @@ -28,14 +28,15 @@ import logging; logger = logging.getLogger(__name__); logger.info("import") from calfbox import cbox #Template Modules +from template.engine.pitch import midiName2midiPitch #dict from template.engine.input_midi import MidiProcessor class Instrument(object): """Literally one instrument. - It might exists in different versions that are all loaded here and can be switched in the GUI. - For that we identify different .sfz files by a minor version number (see below). + It might exists in different variants and version that are all loaded here and can be + switched in the GUI. All data is provided by the parsed metadata dict, except the filepath of the tar which calfbox needs again here. @@ -73,7 +74,7 @@ class Instrument(object): For example they use a different control scheme (different CC maps) Besides version there is also the option to just name the sfz file anything you want, as a - special variant. Which is problematic: + special variant. What constitues as "Instrument Variant" and what as "New Instrument" must be decided on a case by case basis. For example a different piano than the salamander is surely a new instrument. But putting a blanket over the strings (prepared piano) to muffle the sound is the same physical @@ -119,6 +120,8 @@ 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.currentKeySwitch:int = None # Midi pitch. Default is set on load. def exportStatus(self)->dict: """The call-often function to get the instrument status. Includes only data that can @@ -135,6 +138,8 @@ class Instrument(object): result["state"] = self.enabled #bool result["mixerEnabled"] = self.mixerEnabled #bool 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 return result @@ -202,26 +207,203 @@ class Instrument(object): if not self.enabled: raise RuntimeError(f"{self.name} tried to load variant {variantSfzFileName} but was not yet enabled") - if not variantSfzFileName in self.metadata["variants"]: - raise ValueError("Variant not in list: {} {}".format(variantSfzFileName, self.metadata["variants"])) + if not variantSfzFileName in self.variants: + raise ValueError("Variant not in list: {} {}".format(variantSfzFileName, self.variants)) logger.info(f"Start loading samples for instrument {variantSfzFileName} with id key {self.idKey}") #help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc. #newProgramNumber = self.instrumentLayer.engine.get_unused_program() - programNumber = self.metadata["variants"].index(variantSfzFileName) #counts from 1 - self.program = self.instrumentLayer.engine.load_patch_from_tar(programNumber, self.tarFilePath, self.rootPrefixPath+variantSfzFileName, self.metadata["name"]) #tar_name, sfz_name, display_name - self.instrumentLayer.engine.set_patch(1, programNumber) #1 is the channel, counting from 1. #TODO: we want this to be on all channels. - #self.program is always None ? + #programNumber = self.variants.index(variantSfzFileName) + #We do NOT use the program number, that was a mistake in the past. Set programNumber to 0 so it overwrites the current file. + + self.program = self.instrumentLayer.engine.load_patch_from_tar(0, self.tarFilePath, self.rootPrefixPath+variantSfzFileName, self.metadata["name"]) #program number fixed to 0, tar_name, sfz_name, display_name + #self.program = self.instrumentLayer.engine.get_patches()[0][1] workaround in the past, when load_patch_from_tar returned None by mistake. + #Turns out we do not even need set_patch. + #self.instrumentLayer.engine.set_patch(1, 0) #1 is supposed to be the channel, 0 is the program. But it listens to all 16 channels anyway. #self.instrumentLayer is type DocInstrument #self.instrumentLayer.engine is type SamplerEngine - #self.instrumentLayer.engine.get_patches returns a dict like {0: ('Harpsichord.sfz', , 16)} - #Only ever index 0 is used because we have one patch per port - #self.instrumentLayer.engine.get_patches()[0][1] is the cbox.SamplerProgram. That should have been self.program, but isn't! - self.program = self.instrumentLayer.engine.get_patches()[0][1] - logger.info(self.program.status()) + #self.instrumentLayer.engine.get_patches returns a dict like + # {0: ('Harpsichord.sfz', , 16)} + # This is similar to program.status() which returns + # SamplerProgram:in_use=16 name=CelloEns-KS.sfz program_no=0 sample_dir=./Strings/Cello Section/pizzT/ source_file=./CelloEns-KS.sfz + # This is on program number 0 and listens on all 16 channels. + #Only ever index 0 is used because we have one patch per port + + status = self.program.status() + logger.info(status) + assert status.name == variantSfzFileName, (status.name, variantSfzFileName) + assert status.in_use == 16, status.SamplerProgram + assert status.program_no == 0, status.program_no self.currentVariant = variantSfzFileName + + self.currentVariantKeySwitches = self._parseKeyswitches() + if self.currentVariantKeySwitches and self.currentVariantKeySwitches[3]: #[3] is sw_default + self.currentKeySwitch = self.currentVariantKeySwitches[3] + else: + self.currentKeySwitch = None + logger.info(f"Finished loading samples for instrument {variantSfzFileName} with id key {self.idKey}") + def _parseKeyswitches(self): + """ + Called only by chooseVariant. This is a function only for readability reasons and for the + docstring. + + Returns a tuple: dict, sw_lokey, sw_highkey + + 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. + + 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 + keyswitches. + + This is currently a function to find the most common keyswitches, not every advanced scenario + with sw_previous for context-sensitive-regions (which isn't really a keyswitch), + nor sw_lokey, sw_hikey and multiple parallel switches per key. + + Specifically it just searches for sw_last, sw_down and sw_up and assumes that any level (master, + group, region) can only use one of the three. + + sw_down and sw_up are implementation-dependent. We must assume that there are instruments that + use these opcodes without specifying sw_lokey and sw_hikey. sw_last requires the range. + For these reasons we cannot do sanity-checking here. We just report all single-key keyswitches. + + Finally it assumes that there is one sw_lokey and one sw_hikey in the whole file, and it + must be in global. No actual keyswitches will be in global. + + For example: + + VSCO Strings - Cello parses as follows: + ( #Dict with all keyswitches and labels. Label can be empty string. + {'d6': ('sw_last', 'D6 Spiccato'), + 'c6': ('sw_last', 'C6 Sustain Vibrato'), + 'd#6': ('sw_last', 'D#6 Pizzicato'), + 'c#6': ('sw_last', 'C#6 Tremolo')}, + '', #sw_lokey not set + '') #sw_highkey not set + + Calfbox with default settings will report keys as string, + no matter if they are entered as pitch-numbers or keystrings in the .sfz file itself. + We convert them here ourselves. + + We assume there can only be one sw_default. We allow redundant entries but will just + use the last value we encounter. Logical consistency must be checked with an external tool + or process. + """ + + if not self.enabled: + logger.warning(f"Something tried to set the keyswitch but this instrument {self.name} {self.currentVariant} is currently not enabled. Nothing was changed.") + return + if not self.currentVariant: + logger.warning(f"Something tried to parse keyswitches but this instrument {self.name} {self.currentVariant} currently has no variant loaded. Nothing was changed.") + return + + def findKS(data, writeInResult, writeInOthers): + if "sw_label" in data: + label = data["sw_label"] + else: + label = "" + + if "sw_default" in data: + if "sw_default" in writeInOthers and writeInOthers["sw_default"] and writeInOthers["sw_default"] != data["sw_default"]: + logger.error(f"Instrument {self.name} {self.currentVariant} has multiple different sw_default values. We will use the last one encountered. This conflict: {writeInOthers['sw_default']} vs {data['sw_default']} ") + writeInOthers["sw_default"] = data["sw_default"] + + if "sw_last" in data: + midiPitch = midiName2midiPitch[data["sw_last"]] + writeInResult[midiPitch] = "sw_last", label + elif "sw_down" in data: + midiPitch = midiName2midiPitch[data["sw_down"]] + writeInResult[midiPitch] = "sw_down", label + elif "sw_up" in data: + midiPitch = midiName2midiPitch[data["sw_up"]] + writeInResult[midiPitch] = "sw_up", label + + logger.info(f"Start parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}") + + result = {} # int:tuple(string) + others = {} # var:var + hierarchy = self.program.get_hierarchy() + 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) + if v1: + for k2,v2 in v1.items(): #Group + findKS(k2.as_dict(), result, others) + if v2: + for k3,v3 in v2.items(): #Regions + findKS(k3.as_dict(), result, others) + + 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 + else: + return (result, swlokeyValue, swhikeyValue, midiName2midiPitch[others["sw_default"]]) + + + def setKeySwitch(self, keySwitchMidiPitch:int): + """Set the current variant to it's keySwitch number keySwitchIndex. + We use the key-midi-pitch directly. + We also check against our own parsing on startup if the variant has this keyswitch and + do a logger warning if the keyswitch does not exist, and then do nothing. + + Keyswitches get internally reset on variant-switching and use the one specified as default + in the variant-.sfz. + """ + if not self.enabled: + logger.warning(f"Tried to set the keyswitch but this instrument {self.name} is currently not enabled. Nothing was changed.") + return + if not self.currentVariant: + logger.warning(f"Tried to parse keyswitches but this instrument {self.name} currently has no variant loaded. Nothing was changed.") + return + + cboxReportedKeySwitchesRange = self.program.get_keyswitch_groups()[0] # This is ALWAYS two values. It is a range! + keySwitchDict = self.currentVariantKeySwitches[0] + + if not keySwitchMidiPitch in keySwitchDict: + logger.warning(f"Tried setting instrument {self.name} to key switch {keySwitchMidiPitch} but that switch was not parsed on load. Nothing was changed. We parsed: {keySwitchDict} in the range from/to {cboxReportedKeySwitchesRange}") + return + + self.scene.send_midi_event(0x90, keySwitchMidiPitch, 64) #note on with vel 64 + currentKeySwitch = self.instrumentLayer.engine.get_keyswitch_state(1, 0) #midi channel indexFrom1, keyswitch group + #Confirm that we changed the switch for both development and release version + assert currentKeySwitch == keySwitchMidiPitch, (currentKeySwitch, keySwitchMidiPitch) + if not currentKeySwitch == keySwitchMidiPitch: + logger.error(f"Tried setting instrument {self.name} to key switch {keySwitchMidiPitch} but afterwards we are switch {currentKeySwitch}. Cause unknown. Not sure if this is a problem or not.") + + return self.updateCurrentKeySwitch() + + def updateCurrentKeySwitch(self, force=None): + """This is either called directly by setKeySwitch after a user change + but also from our midi event checker in python. + Returns a tuple (stateChangedSinceLastCheck:bool, keySwitchMidiPitch) + + This is called on every(!) note on. + + This is only for permanent sw_last, not momentary sw_up and sw_down + """ + if not self.currentVariantKeySwitches: + return #optimisation. + + if not force is None: + changed = self.currentKeySwitch != force + new = force + else: + new = self.instrumentLayer.engine.get_keyswitch_state(1, 0) #midi channel indexFrom1, keyswitch group(!! ugly !!. We don't support that at all.) + changed = self.currentKeySwitch != new + + self.currentKeySwitch = new + + return changed, new + + def enable(self): """While the instrument ini was already parsed on program start we only create @@ -365,7 +547,7 @@ class Instrument(object): def serialize(self)->dict: return { "id" : self.id, #for convenience access - "currentVariant" : self.currentVariant, #string. Since currentVariant is set to "" when disabling an instrument this is also our marker + "currentVariant" : self.currentVariant, #string. Since currentVariant is set to "" when disabling an instrument this is also our marker for the instrument loaded state. "mixerLevel" : self.mixerLevel, #float "mixerEnabled" : self.mixerEnabled, #bool #Do NOT save "self.enabled". This is just an internal convenience switch. currentVariant is the data that tells us if there was an actively loaded instrument, inluding loaded samples. diff --git a/engine/main.py b/engine/main.py index 1d321f2..c2a1b3e 100644 --- a/engine/main.py +++ b/engine/main.py @@ -90,7 +90,7 @@ class Data(TemplateData): if not self.libraries: logger.error("There were no sample libraries to parse! This is correct on the first run, since you still need to choose a sample directory.") - self.instrumentMidiNoteOnActivity = None # 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. The instruments individiual midiprocessor will call this as a parent-call. + self.instrumentMidiNoteOnActivity = None # 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._createGlobalPorts() #in its own function for readability self._createCachedJackMetadataSorting() diff --git a/qtgui/auditioner.py b/qtgui/auditioner.py index 2e88c86..36b7ea1 100644 --- a/qtgui/auditioner.py +++ b/qtgui/auditioner.py @@ -71,7 +71,7 @@ class AuditionerMidiInputComboController(object): self.currentInstrumentLabel.setText(t) def callback__auditionerVolumeChanged(self, value:float): - self.volumeDial.setValue(value) + self.volumeDial.setValue(int(value)) self.parentMainWindow.statusBar().showMessage(QtCore.QCoreApplication.translate("Auditioner", "Auditioner Volume: {}").format(value)) def _sendVolumeChangeToEngine(self, newValue): diff --git a/qtgui/designer/mainwindow.py b/qtgui/designer/mainwindow.py index 2969ed7..75b66cb 100644 --- a/qtgui/designer/mainwindow.py +++ b/qtgui/designer/mainwindow.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'mainwindow.ui' # -# Created by: PyQt5 UI code generator 5.15.4 +# Created by: PyQt5 UI code generator 5.15.6 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -136,17 +136,23 @@ class Ui_MainWindow(object): self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") self.formLayout = QtWidgets.QFormLayout(self.scrollAreaWidgetContents) self.formLayout.setObjectName("formLayout") - self.variant_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) - self.variant_label.setObjectName("variant_label") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.variant_label) self.variants_comboBox = QtWidgets.QComboBox(self.scrollAreaWidgetContents) self.variants_comboBox.setObjectName("variants_comboBox") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.variants_comboBox) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.variants_comboBox) + self.variant_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) + self.variant_label.setObjectName("variant_label") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.variant_label) self.info_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) self.info_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.info_label.setWordWrap(True) self.info_label.setObjectName("info_label") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.info_label) + self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.info_label) + self.keySwitch_comboBox = QtWidgets.QComboBox(self.scrollAreaWidgetContents) + self.keySwitch_comboBox.setObjectName("keySwitch_comboBox") + self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.keySwitch_comboBox) + self.keySwitch_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) + self.keySwitch_label.setObjectName("keySwitch_label") + 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) @@ -180,3 +186,4 @@ class Ui_MainWindow(object): self.details_groupBox.setTitle(_translate("MainWindow", "NamePlaceholder")) self.variant_label.setText(_translate("MainWindow", "Variants")) self.info_label.setText(_translate("MainWindow", "TextLabel")) + self.keySwitch_label.setText(_translate("MainWindow", "KeySwitch")) diff --git a/qtgui/designer/mainwindow.ui b/qtgui/designer/mainwindow.ui index 95fea00..7a267d4 100644 --- a/qtgui/designer/mainwindow.ui +++ b/qtgui/designer/mainwindow.ui @@ -339,17 +339,17 @@ - + + + + Variants - - - - + TextLabel @@ -362,6 +362,16 @@ + + + + + + + KeySwitch + + + diff --git a/qtgui/instrument.py b/qtgui/instrument.py index 5e1c89b..cf67797 100644 --- a/qtgui/instrument.py +++ b/qtgui/instrument.py @@ -421,7 +421,9 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): if self.state: #either reload or load for the first time self.mixSendDial.setEnabled(True) - self.mixSendDial.setValue(instrumentStatus["mixerLevel"]) + #Qslider can only have integer values. mixerLevel" is float. + #We simply drop the float part, it doesn't matter for our coarse in-house mixing. + self.mixSendDial.setValue(int(instrumentStatus["mixerLevel"])) if instrumentStatus["mixerEnabled"]: muteText = "" else: diff --git a/qtgui/selectedinstrumentcontroller.py b/qtgui/selectedinstrumentcontroller.py index e6459d9..624a3cb 100644 --- a/qtgui/selectedinstrumentcontroller.py +++ b/qtgui/selectedinstrumentcontroller.py @@ -50,14 +50,12 @@ class SelectedInstrumentController(object): self.ui.details_scrollArea.hide() #until the first instrument was selected 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) - - def directLibrary(self, idkey:tuple): """User clicked on a library treeItem""" libraryId, instrumentId = idkey @@ -66,6 +64,9 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.hide() self.ui.variant_label.hide() + self.ui.keySwitch_label.hide() + self.ui.keySwitch_comboBox.hide() + metadata = self.engineData[libraryId]["library"] self.ui.details_groupBox.setTitle(metadata["name"]) @@ -88,6 +89,41 @@ class SelectedInstrumentController(object): 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 instrumentChanged(self, idkey:tuple): """This is a GUI-internal function. The user selected a different instrument from the list. Single click, arrow keys etc. @@ -100,10 +136,17 @@ class SelectedInstrumentController(object): 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() @@ -117,6 +160,9 @@ class SelectedInstrumentController(object): 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"] @@ -125,6 +171,14 @@ class SelectedInstrumentController(object): #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["id-key"] libraryId, instrumentId = idkey @@ -133,6 +187,10 @@ class SelectedInstrumentController(object): 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] @@ -145,8 +203,7 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.setEnabled(False) defaultVariantIndex = instrumentData["variantsWithoutSfzExtension"].index(instrumentData["defaultVariantWithoutSfzExtension"]) self.ui.variants_comboBox.setCurrentIndex(defaultVariantIndex) - - + self._populateKeySwitchComboBox(instrumentStatus) def react_initialInstrumentList(self, data:dict): @@ -162,5 +219,3 @@ class SelectedInstrumentController(object): self.react_instrumentStatusChanged """ self.engineData = data - -