Browse Source

wip reloading and sample dir switching

master
Nils 2 years ago
parent
commit
a4f880beed
  1. 131
      engine/api.py
  2. 25
      engine/auditioner.py
  3. 30
      engine/instrument.py
  4. 178
      engine/main.py
  5. 107
      engine/resources/000 - Default.ini
  6. BIN
      engine/resources/000 - Default.tar
  7. 14
      qtgui/auditioner.py
  8. 3
      qtgui/chooseDownloadDirectory.py
  9. 50
      qtgui/instrument.py
  10. 13
      qtgui/mainwindow.py
  11. 15
      template/qtgui/eventloop.py
  12. 2
      template/qtgui/menu.py

131
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

25
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.

30
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

178
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

107
engine/resources/000 - Default.ini

@ -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

BIN
engine/resources/000 - Default.tar

Binary file not shown.

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

3
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()

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

13
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):

15
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"""

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

Loading…
Cancel
Save