From a4f880beedf791e4cddd8609070931cc9864bbb4 Mon Sep 17 00:00:00 2001 From: Nils <> Date: Thu, 27 Jan 2022 17:29:43 +0100 Subject: [PATCH] wip reloading and sample dir switching --- engine/api.py | 131 +++++++++++++++------ engine/auditioner.py | 25 ++-- engine/instrument.py | 30 ++++- engine/main.py | 178 ++++++++++++++++++++++++----- engine/resources/000 - Default.ini | 107 ----------------- engine/resources/000 - Default.tar | Bin 20480 -> 20480 bytes qtgui/auditioner.py | 14 ++- qtgui/chooseDownloadDirectory.py | 3 +- qtgui/instrument.py | 50 +++++--- qtgui/mainwindow.py | 13 +++ template/qtgui/eventloop.py | 15 ++- template/qtgui/menu.py | 2 +- 12 files changed, 343 insertions(+), 225 deletions(-) delete mode 100644 engine/resources/000 - Default.ini diff --git a/engine/api.py b/engine/api.py index b008b46..c3d7834 100644 --- a/engine/api.py +++ b/engine/api.py @@ -45,6 +45,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks self.tempCallback = [] + self.rescanSampleDir = [] self.instrumentListMetadata = [] self.startLoadingSamples = [] self.instrumentStatusChanged = [] @@ -60,6 +61,14 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks func(export) callbacks._dataChanged() + def _rescanSampleDir(self): + """instructs the GUI to forget all cached data and start fresh. + Pure signal without parameters. + + This only happens on actual, manually instructed rescanning through the api. + The program start happens without that and just sends data into a prepared but empty GUI.""" + for func in self.rescanSampleDir: + func() def _instrumentListMetadata(self): """All libraries with all instruments as meta-data dicts. Hirarchy. @@ -72,7 +81,8 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks def _instrumentStatusChanged(self, libraryId:int, instrumentId:int): """For example the current variant changed""" - export = session.data.libraries[libraryId].instruments[instrumentId].exportStatus() + lib = session.data.libraries[libraryId] + export = lib.instruments[instrumentId].exportStatus() for func in self.instrumentStatusChanged: func(export) callbacks._dataChanged() @@ -97,8 +107,16 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks func(key) def _auditionerInstrumentChanged(self, libraryId:int, instrumentId:int): - """We just send the key. It is assumed that the receiver already has a key:metadata database""" - export = session.data.libraries[libraryId].instruments[instrumentId].exportMetadata() + """We just send the key. It is assumed that the receiver already has a key:metadata database. + We use the metadata of the actual instrument, and not an auditioner copy. + + None for one of the keys means the Auditioner instrument was unloaded. + """ + if libraryId is None or instrumentId is None: + export = None + else: + export = session.data.libraries[libraryId].instruments[instrumentId].exportMetadata() + for func in self.auditionerInstrumentChanged: func(export) @@ -119,7 +137,7 @@ from template.engine.api import callbacks _templateStartEngine = startEngine def startEngine(nsmClient, additionalData): - session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"]) + session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity) _templateStartEngine(nsmClient) #loads save files or creates empty structure. @@ -134,14 +152,11 @@ def startEngine(nsmClient, additionalData): logger.info("We started from a save-file. Restoring saved state now:") session.data.loadCachedSerializedData() - #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 + callbacks._instrumentListMetadata() #Relatively quick. Happens only after a reload of the sample dir. #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(): + for instrument in session.data.allInstr(): callbacks._instrumentStatusChanged(*instrument.idKey) callbacks._auditionerVolumeChanged() @@ -149,10 +164,45 @@ 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 _instr(idKey:tuple)->Instrument: + return session.data.libraries[idKey[0]].instruments[idKey[1]] + + +def rescanSampleDirectory(newBaseSamplePath): + """If the user wants to change the sample dir during runtime we use this function + to rescan. Also useful after the download manager did it's task. + + In opposite to switching a session and reloading samples this function will unload all + and not automatically reload. + + We do not set the sample dir here ourselves. This function can be used to rescan the existing + dir again, in case of new downloaded samples. + """ + logger.info(f"Rescanning sample dir: {newBaseSamplePath}") + + #The auditioner could contain an instrument that got deleted. We unload it to keep it simple. + session.data.auditioner.unloadInstrument() + callbacks._auditionerInstrumentChanged(None, None) + + #The next command will unload all deleted instruments and add all newly found instruments + #However it will not unload and reload instruments that did not change on the surface. + #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) + + 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. + + #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 session.data.allInstr(): + callbacks._instrumentStatusChanged(*instrument.idKey) + def loadAllInstrumentSamples(): """Actually load all instrument samples""" - logger.info(f"Loading all instruments.") - for instrument in Instrument.allInstruments.values(): + logger.info("Loading all instruments.") + for instrument in session.data.allInstr(): callbacks._startLoadingSamples(*instrument.idKey) instrument.loadSamples() callbacks._instrumentStatusChanged(*instrument.idKey) @@ -172,77 +222,82 @@ def unloadAllInstrumentSamples(): The function could also be used from the menu of course. """ logger.info(f"Unloading all instruments.") - for instrument in Instrument.allInstruments.values(): + for instrument in session.data.allInstr(): if instrument.enabled: instrument.disable() - callbacks._instrumentStatusChanged(*instrument.idKey) - callbacks._dataChanged() #Needs to be here to have incremental updates in the gui. + callbacks._instrumentStatusChanged(*instrument.idKey) #Needs to be here to have incremental updates in the gui. + callbacks._dataChanged() -def unloadInstrumentSamples(idkey:tuple): - instrument = Instrument.allInstruments[idkey] - instrument.disable() - callbacks._instrumentStatusChanged(*instrument.idKey) +def unloadInstrumentSamples(idKey:tuple): + instrument = _instr(idKey) + if instrument.enabled: + instrument.disable() + callbacks._instrumentStatusChanged(*instrument.idKey) callbacks._dataChanged() -def loadInstrumentSamples(idkey:tuple): +def loadInstrumentSamples(idKey:tuple): """Load one .sfz from a library.""" - instrument = Instrument.allInstruments[idkey] + instrument = _instr(idKey) + callbacks._startLoadingSamples(*instrument.idKey) if not instrument.enabled: instrument.enable() - instrument.loadSamples() + callbacks._instrumentStatusChanged(*instrument.idKey) callbacks._dataChanged() -def chooseVariantByIndex(idkey:tuple, variantIndex:int): +def chooseVariantByIndex(idKey:tuple, variantIndex:int): """Choose a variant of an already enabled instrument""" - libraryId, instrumentId = idkey - instrument = Instrument.allInstruments[idkey] + instrument = _instr(idKey) instrument.chooseVariantByIndex(variantIndex) callbacks._instrumentStatusChanged(*instrument.idKey) callbacks._dataChanged() -def setInstrumentMixerVolume(idkey:tuple, value:float): +def setInstrumentMixerVolume(idKey:tuple, value:float): """From 0 to -21. Default is -3.0 """ - instrument = Instrument.allInstruments[idkey] + instrument = _instr(idKey) instrument.mixerLevel = value callbacks._instrumentStatusChanged(*instrument.idKey) -def setInstrumentMixerEnabled(idkey:tuple, state:bool): - instrument = Instrument.allInstruments[idkey] +def setInstrumentMixerEnabled(idKey:tuple, state:bool): + instrument = _instr(idKey) instrument.setMixerEnabled(state) callbacks._instrumentStatusChanged(*instrument.idKey) -def setInstrumentKeySwitch(idkey:tuple, keySwitchMidiPitch:int): +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] + instrument = _instr(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): +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) + instrument = _instr(idKey) -def auditionerInstrument(idkey:tuple): + result = instrument.updateCurrentKeySwitch() + if result: #could be None + changed, nowKeySwitchPitch = result + if changed: + callbacks._instrumentStatusChanged(*instrument.idKey) + +def auditionerInstrument(idKey:tuple): """Load an indendepent instance of an instrument into the auditioner port""" - libraryId, instrumentId = idkey + libraryId, instrumentId = idKey callbacks._startLoadingAuditionerInstrument(libraryId, instrumentId) - originalInstrument = Instrument.allInstruments[idkey] + originalInstrument = _instr(idKey) + #It does not matter if the originalInstrument has its samples loaded or not. We just want the path if originalInstrument.currentVariant: var = originalInstrument.currentVariant diff --git a/engine/auditioner.py b/engine/auditioner.py index 859fb27..6c22646 100644 --- a/engine/auditioner.py +++ b/engine/auditioner.py @@ -89,27 +89,9 @@ class Auditioner(object): value = -21 self.outputMergerRouter.set_gain(value) - def exportStatus(self): - """The call-often function to get the instrument status. Includes only data that can - actually change during runtime.""" - result = {} - - #Static ids - result["id"] = self.metadata["id"] - result["id-key"] = self.idKey #redundancy for convenience. - - #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, keySwitchMidiPitch:int): """load_patch_from_tar is blocking. This function will return when the instrument is ready to play. - - The function will do nothing when the instrument is not enabled. """ logger.info(f"Start loading samples for auditioner {variantSfzFileName}") #help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc. @@ -128,6 +110,13 @@ class Auditioner(object): logger.info(f"Finished loading samples for auditioner {variantSfzFileName}") + def unloadInstrument(self): + """Unlike instruments disable this will not remove the midi and audio ports. + But it will remove the loaded instruments, if any, from ram""" + self.scene.status().layers[0].get_instrument().engine.load_patch_from_string(0, "", "", "") #fill with null instruments, hopefully replacing the loaded sfz data. + self.currentVariant = None + self.currentKeySwitch = None + def getAvailablePorts(self)->dict: """This function queries JACK each time it is called. It returns a dict with two lists. diff --git a/engine/instrument.py b/engine/instrument.py index c29a82c..09b5235 100644 --- a/engine/instrument.py +++ b/engine/instrument.py @@ -87,8 +87,6 @@ class Instrument(object): that isn't either an sfz controller or a different instrument (I personally consider the blanket-piano a different instrument)""" - allInstruments = {} # (libraryId, instrumentId):Instrument()-object - def __init__(self, parentLibrary, libraryId:int, metadata:dict, tarFilePath:str, startVariantSfzFilename:str=None): self.parentLibrary = parentLibrary self.metadata = metadata #parsed from the ini file. See self.exportMetadata for the pythonic dict @@ -96,7 +94,6 @@ class Instrument(object): self.libraryId = libraryId self.startVariantSfzFilename = startVariantSfzFilename self.idKey = (self.libraryId, int(self.metadata["id"])) - Instrument.allInstruments[self.idKey] = self self.id = int(self.metadata["id"]) self.enabled = False #At startup no samples and no jack-ports. But we already have all metadata ready so a GUI can build a database and offer Auditioner choices. @@ -143,6 +140,30 @@ class Instrument(object): return result + def copyStateFrom(self, otherInstrument): + """Use another instrument instance to copy the soft values. + Not really sampler internal CC but everything we control on our own""" + assert otherInstrument.idKey == self.idKey, (otherInstrument.idKey, self.idKey) + + if otherInstrument.enabled: + print (self.idKey, otherInstrument.idKey) + self.enable() + self.chooseVariant(otherInstrument.currentVariant) + if self.currentKeySwitch: + self.setKeySwitch(self.currentKeySwitch) + self.mixerLevel = otherInstrument.mixerLevel + else: + self.disable() + + #jack conections + #Ist die alte Verbindung noch hier? Dann können wir nicht jack connections machen. Das teil kriegt sogar den falschen namen! + #Ich könnte die funktion hier doch wieder in lib packen und dann die jack connections parsen, + #die alte lib deaktivieren und dann die neuen instrumente erst aktivieren. schwierig. Das braucht einen Test ob + #die jack connections einen falschen namen kriegen (pretty names ausmachen) + + + + def exportMetadata(self)->dict: """This gets called before the samples are loaded. Only static data, that does not get changed during runtime, is included here. @@ -183,7 +204,6 @@ class Instrument(object): """ Convenience starter. Use this. """ - if not self.enabled: self.enable() if self.startVariantSfzFilename: @@ -231,7 +251,7 @@ class Instrument(object): status = self.program.status() logger.info(status) - assert status.name == variantSfzFileName, (status.name, variantSfzFileName) + assert variantSfzFileName in status.name, (status.name, variantSfzFileName) #this is NOT the same. Salamander Drumkit status.name == 'drumkit/ALL.sfz', variantSfzFileName == 'ALL.sfz' assert status.in_use == 16, status.SamplerProgram assert status.program_no == 0, status.program_no self.currentVariant = variantSfzFileName diff --git a/engine/main.py b/engine/main.py index c2a1b3e..1f3831c 100644 --- a/engine/main.py +++ b/engine/main.py @@ -57,6 +57,7 @@ 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): @@ -64,36 +65,99 @@ class Data(TemplateData): self.cachedSerializedDataForStartEngine = None - def parseAndLoadInstrumentLibraries(self, baseSamplePath): - """Called once by api.startEngine, which receives the global sample path from - the GUI""" - #Parse all tar libraries and load them all. ALL OF THEM! - #for each library in ... - self.libraries = {} # libraryId:int : Library-object + def allInstr(self): + for libId, lib in self.libraries.items(): + for instrId, instr in lib.instruments.items(): + yield instr - s = pathlib.Path(PATHS["share"]) - defaultLibraryPath = s.joinpath("000 - Default.tar") - logger.info(f"Loading default test library from {defaultLibraryPath}") - defaultLib = Library(parentData=self, tarFilePath=defaultLibraryPath) - self.libraries[defaultLib.id] = defaultLib + def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity): + """Called first by api.startEngine, which receives the global sample path from + the GUI. - #basePath = pathlib.Path("/home/nils/samples/Tembro/out/") #TODO: replace with system-finder /home/xy or /usr/local/share or /usr/share etc. - basePath = pathlib.Path(baseSamplePath) #self.baseSamplePath is injected by api.startEngine. - - logger.info(f"Start loading samples from {baseSamplePath}") + Later called by sample dir rescan. + """ + #Since this is a function called by the user, or at least the GUI, + #we do some error checking. + if not baseSamplePath: + raise ValueError(f"Wrong format for argument baseSamplePath. Should be a path-string or path-like object but was: {baseSamplePath}") + + basePath = pathlib.Path(baseSamplePath) + + if not basePath.exists(): + raise OSError(f"{basePath} does not exists to load samples from.") + if not basePath.is_dir(): + raise OSError(f"{basePath} is not a directory..") + + firstRun = not self.libraries + + 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. + s = pathlib.Path(PATHS["share"]) + defaultLibraryPath = s.joinpath("000 - Default.tar") + logger.info(f"Loading Default Instrument Library from {defaultLibraryPath}. This message must only appear once in the log.") + defaultLib = Library(parentData=self, tarFilePath=defaultLibraryPath) + self.libraries[defaultLib.id] = defaultLib + assert defaultLib.id == 0, defaultLib.id + + defaultLib = self.libraries[0] + #Remember the current libraries, in case of a rescan, so we can see which ones were deleted in the meantime. + libsToDelete = set(self.libraries.keys()) #ints + libsToDelete.remove(defaultLib.id) + + logger.info(f"Start opening and parsing instrument metadata from {baseSamplePath}") for f in basePath.glob('*.tar'): if f.is_file() and f.suffix == ".tar": + #First load the library (this is .ini parsing, not sample loading, so it is cheap) and create a library object lib = Library(parentData=self, tarFilePath=f) - self.libraries[lib.id] = lib + #Then compare if this is actually a file we already knew: + + #we loaded this before and it still exists. We will NOT delete it below. + if lib.id in libsToDelete: + libsToDelete.remove(lib.id) + + #If this is a completely new lib it is simple: just load + if not lib.id in self.libraries: + assert not lib.id in libsToDelete, (lib.id, libsToDelete) + self.libraries[lib.id] = lib + else: #we already know this id + oldLib = self.libraries[lib.id] + + print (lib.tarFilePath, oldLib.tarFilePath, lib.tarFilePath.samefile(oldLib.tarFilePath)) + if lib.tarFilePath == oldLib.tarFilePath or lib.tarFilePath.samefile(oldLib.tarFilePath): + #Same id, same file path, or (sym)link. We update the old instrument and maybe there are new variants to load. + #Loaded state will remain the same. Tembro instruments don't change with updates. + self.libraries[lib.id].updateWithNewParse(lib) + else: + #Same id, different file path. We treat it as a different lib and unload/reload completely. + self._unloadLibrary(lib.id) #remove old lib instance + lib.transferOldState(oldLib) #at least reactivate the already loaded instruments. + self.libraries[lib.id] = lib #this is the new lib instance. + + logger.info(f"Finished loading samples from {baseSamplePath}") + #There might still be loaded libraries left that were not present in the file parsing above. + #These are the libs that have been removed as files by the user during runtime. We unload and remove them as well. + if len(libsToDelete) > 0: + logger.info(f"Start removing {len(libsToDelete)} libraries that were removed from {baseSamplePath}") + for libIdToDel in libsToDelete: + self._unloadLibrary(libIdToDel) + logger.info(f"Finished removing deleted libraries.") + 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.") + #TODO: Is this still valid with the guaranteed 000 - Default.tar? + 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 = 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.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. + 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() + def _unloadLibrary(self, libIdToDel): + for instrId, instrObj in self.libraries[libIdToDel].instruments.items(): + if instrObj.enabled: #unload if necessary. + instrObj.disable() + del self.libraries[libIdToDel] #garbage collect all children instruments as well + def _createGlobalPorts(self): """Create two mixer ports, for stereo. Each instrument will not only create their own jack out ports but also connect to these left/right. @@ -133,12 +197,12 @@ class Data(TemplateData): #order = {portName:index for index, portName in enumerate(track.sequencerInterface.cboxPortName() for track in self.tracks if track.sequencerInterface.cboxMidiOutUuid)} highestLibId = max(self.libraries.keys()) - highestInstrId = max(instId for libId, instId in Instrument.allInstruments.keys()) + highestInstrId = max(inst.id for inst in self.allInstr()) clientName = cbox.JackIO.status().client_name order = {} orderCounter = 0 - for (libraryId, instrumentId), instr in Instrument.allInstruments.items(): + for instr in self.allInstr(): L = clientName + ":" + instr.midiInputPortName + "_L" R = clientName + ":" + instr.midiInputPortName + "_R" @@ -217,7 +281,7 @@ class Library(object): This is for GUI data etc. You get all metadata from this. - The samples are not loaded when Library() returns. The API can loop over Instrument.allInstruments + The samples are not loaded when Library() returns. The API can loop over self.allInstr() and call instr.loadSamples() and send a feedback to callbacks. There is also a shortcut. First only an external .ini is loaded, which is much faster than @@ -226,12 +290,13 @@ class Library(object): """ - def __init__(self, parentData, tarFilePath):# + def __init__(self, parentData, tarFilePath): self.parentData = parentData self.tarFilePath = tarFilePath #pathlib.Path() if not tarFilePath.suffix == ".tar": raise RuntimeError(f"Wrong file {tarFilePath}") + self.instruments = {} # instrId : Instrument() logger.info(f"Parsing {tarFilePath}") @@ -258,14 +323,71 @@ class Library(object): self.id = int(self.config["library"]["id"]) instrumentSections = self.config.sections() instrumentSections.remove("library") - instrumentsInLibraryCount = len(instrumentSections) - self.instruments = {} # instrId : Instrument() + self.instrumentsInLibraryCount = len(instrumentSections) + for iniSection in instrumentSections: instrId = int(self.config[iniSection]["id"]) instrObj = Instrument(self, self.id, self.config[iniSection], tarFilePath) - instrObj.instrumentsInLibraryCount = instrumentsInLibraryCount + instrObj.instrumentsInLibraryCount = self.instrumentsInLibraryCount self.instruments[instrId] = instrObj - #At a later point Instrument.loadSamples() must be called. This is done in the API. + #We only parsed the metadata here. No instruments are loaded yet. + #At a later point Instrument.loadSamples() must be called. This is done in the API etc. + + + def updateWithNewParse(self, newLib): #newlib is type Library / self + """We parsed a new version of our file. We are the old version. + This libary will remain loaded but maybe there are new variants in the .tar. + Variants are loaded with new access to the .tar , so we just need to parse the new ini here + and update our internal representation. + + newLib contains the newly parsed ini data. We only want the new metadata from the ini. + newLib also generated new metaInstruments (without any samples loaded) that will just get + discarded. + """ + assert self.tarFilePath == newLib.tarFilePath or newLib.tarFilePath.samefile(self.tarFilePath), (self.tarFilePath, newLib.tarFilePath) + print ("update new parse", (self.tarFilePath, newLib.tarFilePath)) + if newLib.config["library"]["version"] > self.config["library"]["version"]: + self.config = newLib.config + for newInstrId, newInstrument in newLib.instruments.items(): + if newInstrId in self.instruments: + assert newInstrument.defaultVariant == self.instruments[newInstrId].defaultVariant, (newInstrument.defaultVariant, self.instruments[newInstrId].defaultVariant) #this is by Tembro-Design. Never change existing instruments. + for newVariant in newInstrument.variants: #string + if not newVariant in self.instruments[newInstrId].variants: + logger.info(f"Found a new variant {newVariant} while parsing updated version of library {newLib.tarFilePath} and instrument {newInstrument.metadata['name']}") + self.instruments[newInstrId].variants.append(newVariant) + else: + logger.info(f"Found a new instrument {newInstrument.metadata['name']} while parsing updated version of library {newLib.tarFilePath}") + self.instruments[newInstrId] = newInstrument + + #Update various values + #print ("Old count, new count", self.instrumentsInLibraryCount, newLib.instrumentsInLibraryCount) + self.instrumentsInLibraryCount = newLib.instrumentsInLibraryCount + for instr in self.instruments.values(): + instr.instrumentsInLibraryCount = newLib.instrumentsInLibraryCount + + elif newLib.config["library"]["version"] < self.config["library"]["version"]: + raise ValueError(f"""Attempted to 'update' library {self.tarFilePath} version {self.config["library"]["version"]} with older version {newLib.config["library"]["version"]}. This is not allowed in Tembro during runtime. Aborting program.""") + else: + #otherwise this is literally the same file with the same id and same version. -> no action required. + pass + + + def transferOldState(self, oldLib): #oldLib is type Library / self + """We are a newly parsed Library that will replace an existing one. + The new and old library ID are the same, but the filepath is different. + + The old one maybe has loaded instruments. We will load these instruments. oldLib contains the state + up until now. + + This happens when the sample dir is changed during runtime with the same, or updated, + files. In opposite to updateWithNewParse we don't need to worry about incrementally + getting new instruments and variants. We just load the whole lib and then pick runtime + data from the old one, before discarding it. + + """ + print ("transferOldState", self.tarFilePath, oldLib.tarFilePath) + for instrId, oldInstrument in oldLib.instruments.items(): + self.instruments[instrId].copyStateFrom(oldInstrument) def exportMetadata(self)->dict: """Return a dictionary with each key is an instrument id, but also a special key "library" @@ -283,7 +405,7 @@ class Library(object): libDict["description"] = self.config["library"]["description"] libDict["license"] = self.config["library"]["license"] libDict["vendor"] = self.config["library"]["vendor"] - + libDict["version"] = self.config["library"]["version"] #this is not the upstream sfz version of the sample creator but our own library index. flat integers as counters. higher is newer. for instrument in self.instruments.values(): result[instrument.id] = instrument.exportMetadata() #another dict diff --git a/engine/resources/000 - Default.ini b/engine/resources/000 - Default.ini deleted file mode 100644 index c8c6099..0000000 --- a/engine/resources/000 - Default.ini +++ /dev/null @@ -1,107 +0,0 @@ -[library] -;All parameters must be set. -;Library ID is unique across all Tembro Library files -id=0 -name=Tembro Default Instrument -description=This uses the sfz-Synthesizer features, and not samples. If this is the only instrument - you see you have not (correctly) downloaded the sample files and set the correct path via - the Edit menu. -license=Instrument does not reach German copyright requirements. It is true public domain, or rather "Gemeinfrei". -vendor=Hilbricht Nils 2021, Laborejo Software Suite https://www.laborejo.org info@laborejo.org - -;;;;;;;;;;;;;;;;;;; -;;;;Instruments;;;; -;;;;;;;;;;;;;;;;;;; - -;Each instrument creates its own JACK midi port and audio output ports. - -;Instrument [section] names have no meaning except to differentiate them from "library". -;The explict id (see below) is the permanent instrument identifier. -[sine] -;Instrument parameters that are mandatory: -;Instrument ID is unique within this library file -id=0 -;The pretty name for the whole instrument. If you cannot find a name that describes all variants -;(see below) that is a sign that the variants are not the same instrument. -name=Sine Wave - -;Variants are case sensitive and comma separated. Sorry, no commas in sfz file names here. -;No space-padding. Spaces in filenames are allowed. -;The order and naming of these files MUST remain permanently the same. This is most likely a version history. -variants=Sine.sfz -;Which variant shall be loaded when no save file exists. When updated versions are created this may change. -defaultVariant=Sine.sfz - -description=Sine wave synthesizer. - -;Tags are comma separated keywords for search and filtering. Do not create arbitrary tags. They are premade. -tags=sine - -;Instrument parameters that are optional. With explanation why: - -;Additional Vendor notes. They are specific to this instrument. -;Use them to add additional persons or explain differences to -;the library author information provided above -;UNCOMMENT vendor=Test entry to provide more vendor information - - -;An instrument license entry will override the library one. The license will be shown in each -;instruments GUI entry. Either the default one or *only* the license one. This is different to the -;author entry, where additional information is just appended. -;UNCOMMENT license=https://unlicense.org/ - -;Group. While tags are a global way to group across all libraries a group is a one-level structure -;within a library. Instruments without a group are top-level instruments (within the lib). -;Intended mainly for common groups like orchstra-sections (brass, strings etc.) -group=synthesizer - -;From here on just the basics: -[saw] -id=1 -name=Saw Wave -variants=Saw.sfz -defaultVariant=Saw.sfz -description=Saw wave synthesizer -tags=saw -group=synthesizer - -[square] -id=2 -name=Square Wave -variants=Square.sfz -defaultVariant=Square.sfz -description=Square wave synthesizer. -tags=Square -group=synthesizer - -[triangle] -id=3 -name=Triangle Wave -variants=Triangle.sfz -defaultVariant=Triangle.sfz -description=Triangle wave synthesizer. -tags=Triangle -group=synthesizer - -[noise] -id=4 -name=Noise -variants=Noise.sfz -defaultVariant=Noise.sfz -description=Noise from a synthesizer. Does not work yet, needs engine more support. -tags=Noise -group=synthesizer - -[silence] -id=5 -name=Silence -variants=Silence.sfz -defaultVariant=Silence.sfz -description=Literally silence, but it is synthesized nevertheless, and goes through the whole audio-chain in the program. - If you find a usecase for this please send an email to info@laborejo.org :) -tags=Silence -group=synthesizer - - - - diff --git a/engine/resources/000 - Default.tar b/engine/resources/000 - Default.tar index b8967fd0394e3616acd75c8966c8a92902997813..fdcd7deb10b583757c4901780fc2c3d7b7f295c3 100644 GIT binary patch delta 60 zcmZozz}T>WaYCzvu>pgjiJ`fPv5}#Xff<8=p`p2vDT9K+WJAW3&5VqRjGSeuMa7x< QdA5d|6Pc#+E)sA60M?Ta=>Px# delta 53 zcmZozz}T>WaYCzvp&5gr2@skZo0^#!GZ+{enwuChC>TsOWK7x2$e75uIfrQq??eTz HMFI{0ZdeUF diff --git a/qtgui/auditioner.py b/qtgui/auditioner.py index 36b7ea1..3062bcf 100644 --- a/qtgui/auditioner.py +++ b/qtgui/auditioner.py @@ -44,7 +44,8 @@ class AuditionerMidiInputComboController(object): self.volumeDial.leaveEvent = lambda ev: self.parentMainWindow.statusBar().showMessage("") self.wholePanel = parentMainWindow.ui.auditionerWidget self.currentInstrumentLabel = parentMainWindow.ui.auditionerCurrentInstrument_label - self.currentInstrumentLabel.setText(QtCore.QCoreApplication.translate("Auditioner", "Double click on an instrument to load it into the Auditioner")) + self.defaultText = QtCore.QCoreApplication.translate("Auditioner", "Double click on an instrument to load it into the Auditioner") + self.currentInstrumentLabel.setText(self.defaultText) #if not api.isStandaloneMode(): #self.wholePanel.hide() @@ -65,10 +66,13 @@ class AuditionerMidiInputComboController(object): self.parentMainWindow.qtApp.processEvents() #actually show the label and cursor def callback_auditionerInstrumentChanged(self, exportMetadata:dict): - self.parentMainWindow.qtApp.restoreOverrideCursor() #We assume the cursor was set to a loading animation - key = exportMetadata["id-key"] - t = f"➜ [{key[0]}-{key[1]}] {exportMetadata['name']}" - self.currentInstrumentLabel.setText(t) + if exportMetadata is None: + self.currentInstrumentLabel.setText(self.defaultText) + else: + self.parentMainWindow.qtApp.restoreOverrideCursor() #We assume the cursor was set to a loading animation + key = exportMetadata["id-key"] + t = f"➜ [{key[0]}-{key[1]}] {exportMetadata['name']}" + self.currentInstrumentLabel.setText(t) def callback__auditionerVolumeChanged(self, value:float): self.volumeDial.setValue(int(value)) diff --git a/qtgui/chooseDownloadDirectory.py b/qtgui/chooseDownloadDirectory.py index 131d157..eb92beb 100644 --- a/qtgui/chooseDownloadDirectory.py +++ b/qtgui/chooseDownloadDirectory.py @@ -35,6 +35,7 @@ from .designer.chooseDownloadDirectory import Ui_ChooseDownloadDirectory from .resources import * #has the translation #Client Modules +import engine.api as api from engine.config import * #imports METADATA from qtgui.resources import * #Has the logo @@ -84,9 +85,9 @@ class ChooseDownloadDirectory(QtWidgets.QDialog): #There is no guarantee that the dir really exists. but at this point the user is on its own. #It is allowed to use /dev/null after all settings.setValue("sampleDownloadDirectory", self.path) + api.rescanSampleDirectory(self.path) super().accept() def reject(self): self.path = None super().reject() - diff --git a/qtgui/instrument.py b/qtgui/instrument.py index cf67797..b95899a 100644 --- a/qtgui/instrument.py +++ b/qtgui/instrument.py @@ -52,10 +52,12 @@ class InstrumentTreeController(object): self.parentMainWindow = parentMainWindow self.treeWidget = self.parentMainWindow.ui.instruments_treeWidget - self._cachedData = None - self._cachedLastInstrumentStatus = {} # instrument idkey : status Dict - self.guiLibraries = {} # id-key : GuiLibrary - self.guiInstruments = {} # id-key : GuiInstrument + self.reset() + #Includes: + #self._cachedData = None + #self._cachedLastInstrumentStatus = {} # instrument idKey : status Dict + #self.guiLibraries = {} # id-key : GuiLibrary + #self.guiInstruments = {} # id-key : GuiInstrument self.currentlyNested = None #is the view nested in libraries or just all instruments? @@ -95,6 +97,15 @@ class InstrumentTreeController(object): #Default values are used in self.buildTree + def reset(self): + """Used on creation and after resetting the sample dir""" + self._cachedData = None + self._cachedLastInstrumentStatus = {} # instrument idKey : status Dict + #The next two will delete all children through the garbage collector. + self.guiLibraries = {} # id-key : GuiLibrary + self.guiInstruments = {} # id-key : GuiInstrument + + def itemExpandedOrCollapsed(self, libraryItem:QtWidgets.QTreeWidgetItem): #print (libraryItem.name, libraryItem.isExpanded()) api.session.guiSharedDataToSave["libraryIsExpanded"][libraryItem.id] = libraryItem.isExpanded() @@ -110,14 +121,14 @@ class InstrumentTreeController(object): assert len(selItems) == 1, selItems #because our selection is set to single. item = selItems[0] if type(item) is GuiInstrument: - self.parentMainWindow.selectedInstrumentController.instrumentChanged(item.idkey) + self.parentMainWindow.selectedInstrumentController.instrumentChanged(item.idKey) else: - self.parentMainWindow.selectedInstrumentController.directLibrary(item.idkey) + self.parentMainWindow.selectedInstrumentController.directLibrary(item.idKey) def itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int): """This chooses the auditioner. Callbacks are handled in the auditioner widget itself""" if type(item) is GuiInstrument: - api.auditionerInstrument(item.idkey) + api.auditionerInstrument(item.idKey) @@ -262,18 +273,18 @@ class InstrumentTreeController(object): #We also cache the last status, as we cache the initial data. This way we can delete and recreate TreeItems without requesting new status data from the engine self._cachedLastInstrumentStatus[instrumentStatus["id-key"]] = instrumentStatus - def react_startLoadingSamples(self, idkey:tuple): + def react_startLoadingSamples(self, idKey:tuple): """Will be overriden by instrument status change / variant chosen""" self.parentMainWindow.qtApp.setOverrideCursor(QtCore.Qt.WaitCursor) #reset in self.react_instrumentStatusChanged text = QtCore.QCoreApplication.translate("InstrumentTreeController", "…loading…") loadedIndex = COLUMNS.index("loaded") - instr = self.guiInstruments[idkey] + instr = self.guiInstruments[idKey] 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): #First figure out which instrument has activity - gi = self.guiInstruments[idkey] + gi = self.guiInstruments[idKey] gi.activity() @@ -284,7 +295,7 @@ class GuiLibrary(QtWidgets.QTreeWidgetItem): super().__init__([], type=1000) #type 0 is default qt type. 1000 is subclassed user type) self.id = libraryDict["id"] - self.idkey = (libraryDict["id"], 0) #fake it for compatibility + self.idKey = (libraryDict["id"], 0) #fake it for compatibility self.name = libraryDict["name"] @@ -328,7 +339,7 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): def __init__(self, parentTreeController, instrumentDict): GuiInstrument.allItems[instrumentDict["id-key"]] = self self.parentTreeController = parentTreeController - self.idkey = instrumentDict["id-key"] + self.idKey = instrumentDict["id-key"] #Start with empty columns. We fill in later in _writeColumns super().__init__([], type=1000) #type 0 is default qt type. 1000 is subclassed user type) @@ -401,9 +412,9 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): """Only GUI clicks. Does not react to the engine callback that switches on instruments. For example one that arrives through "load group" or "load all" """ if state: - api.loadInstrumentSamples(self.idkey) + api.loadInstrumentSamples(self.idKey) else: - api.unloadInstrumentSamples(self.idkey) + api.unloadInstrumentSamples(self.idKey) def updateStatus(self, instrumentStatus:dict): #Before we set the state permanently we use the opportunity to see if this is program state (state == None) or unloading @@ -413,7 +424,7 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): self.currentVariant = instrumentStatus["currentVariant"] #if instrumentStatus["currentVariant"]: #None if not loaded or not enabled anymore self.setText(variantColumnIndex, instrumentStatus["currentVariant"].rstrip(".sfz")) #either "" or a variant - self.state = instrumentStatus["state"] + self.state = instrumentStatus["state"] #is no bool and not None. self.toggleSwitch.setChecked(instrumentStatus["state"]) self.mixSendDial.setStyleSheet("") self.mixSendDial.setUpdatesEnabled(True) @@ -436,6 +447,7 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): #the instrument was once loaded and is currently connected api.session.eventLoop.verySlowDisconnect(self._activityOff) + if not self.state: #in any not-case self.mixSendDial.setEnabled(False) self.mixSendDial.setUpdatesEnabled(False) #this is a hack to make the widget disappear. Because hiding a QTreeWidgetItem does not work. @@ -446,10 +458,10 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): def _mixSendDialContextMenuEvent(self, event): if self._cachedInstrumentStatus["mixerEnabled"]: mixerMuteText = QtCore.QCoreApplication.translate("InstrumentMixerLevelContextMenu", "Mute/Disable Mixer-Send for {}".format(self.instrumentDict["name"])) - mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idkey, False) + mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idKey, False) else: mixerMuteText = QtCore.QCoreApplication.translate("InstrumentMixerLevelContextMenu", "Unmute/Enable Mixer-Send for {}".format(self.instrumentDict["name"])) - mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idkey, True) + mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idKey, True) listOfLabelsAndFunctions = [ (mixerMuteText, mixerMuteFunc), @@ -532,5 +544,5 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem): def _sendVolumeChangeToEngine(self, newValue): self.mixSendDial.blockSignals(True) - api.setInstrumentMixerVolume(self.idkey, newValue) + api.setInstrumentMixerVolume(self.idKey, newValue) self.mixSendDial.blockSignals(False) diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index 71332e0..64ad7ea 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -99,6 +99,8 @@ class MainWindow(TemplateMainWindow): else: additionalData["baseSamplePath"] = "/tmp" #TODO: At least give a message. + 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. + 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. #Statusbar will show possible actions, such as "use scrollwheel to transpose" @@ -128,6 +130,17 @@ class MainWindow(TemplateMainWindow): self.menu.orderSubmenus(["menuFile", "menuEdit", "menuView", "menuHelp"]) + def react_rescanSampleDir(self): + """instructs the GUI to forget all cached data and start fresh. + Pure signal without parameters. + + This only happens on actual, manually instructed rescanning through the api. + The program start happens without that and just sends data into a prepared but empty GUI.""" + + self.instrumentTreeController.reset() + + + def zoom(self, scaleFactor:float): pass def stretchXCoordinates(self, factor): diff --git a/template/qtgui/eventloop.py b/template/qtgui/eventloop.py index 24c40df..b0ee1f2 100644 --- a/template/qtgui/eventloop.py +++ b/template/qtgui/eventloop.py @@ -62,15 +62,24 @@ class EventLoop(object): def fastDisconnect(self, function): """The function must be the exact instance that was registered""" - self.fastLoop.timeout.disconnect(function) + try: + self.fastLoop.timeout.disconnect(function) + except TypeError: #'method' object is not connected + pass def slowDisconnect(self, function): """The function must be the exact instance that was registered""" - self.slowLoop.timeout.disconnect(function) + try: + self.slowLoop.timeout.disconnect(function) + except TypeError: #'method' object is not connected + pass def verySlowDisconnect(self, function): """The function must be the exact instance that was registered""" - self.verySlowLoop.timeout.disconnect(function) + try: + self.verySlowLoop.timeout.disconnect(function) + except TypeError: #'method' object is not connected + pass def start(self): """The event loop MUST be started after the Qt Application instance creation""" diff --git a/template/qtgui/menu.py b/template/qtgui/menu.py index 443cb2b..1d71ad0 100644 --- a/template/qtgui/menu.py +++ b/template/qtgui/menu.py @@ -101,6 +101,7 @@ class Menu(object): def _setupDebugMenu(self): """Debug entries are not translated""" def guiCallback_menuDebugVisibility(state): + print (state) self.ui.menuDebug.menuAction().setVisible(state) api.session.guiSharedDataToSave["actionToggle_Debug_Menu_Visibility"] = state @@ -397,4 +398,3 @@ class HoverActionDictionary(dict): menuAction.setShortcut(QtGui.QKeySequence(self.qShortcuts[key])) api.session.guiSharedDataToSave["hoverActionDictionary"][menuAction.text()] = "Num+"+key dict.__setitem__(self, key, menuAction) -