Browse Source

Full support for multi-audio-out instruments. Port names, sfz labels, port sorting, internal mixer and auditioner routing

Nils 3 weeks ago
  1. 4
  2. 2
  3. 227
  4. 56
  5. 3


@ -283,7 +283,7 @@ def chooseVariantByIndex(idKey:tuple, variantIndex:int):
def setInstrumentMixerVolume(idKey:tuple, value:float):
"""From 0 to -21.
Default is -3.0 """
Default is -1.0 """
instrument = _instr(idKey)
instrument.mixerLevel = value
@ -344,7 +344,7 @@ def connectAuditionerPort(externalPort:str):
def setAuditionerVolume(value:float):
"""From 0 to -21.
Default is -3.0 """
Default is -1.0 """ = value


@ -65,7 +65,7 @@ class Auditioner(object):
jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R")
self.outputMergerRouter = cbox.JackIO.create_audio_output_router(jackAudioOutLeft, jackAudioOutRight)
instrument = layer.get_instrument()
instrument.get_output_slot(0).rec_wet.attach(self.outputMergerRouter) #output_slot is 0 based and means a pair. Most sfz instrument have only one stereo pair. #TODO: And what if not?


@ -47,39 +47,6 @@ class Instrument(object):
The order of variants in the config file will never change, it can only get appended.
This way indexing will remain consistent over time.
The instruments are not really described by semantic versioning. That was the initial plan
but it showed that versioning of third party instruments is difficult or impossible.
But it also is not needed nor practical. New versions are so rare that one can easily
find individual schemes to name and differentiate variants.
The default variant after the first start (no save file) is the a special entry in metadata.
It can change with new versions, so new projects will start with the newer file.
Here we have versions 1.2, 1.3 and 1.6. 4 and 5 were never released. A dropdown in a GUI
would show these entries.
Patches are differentiated by the MINOR version as int. MINOR versions slightly change the sound.
Typical reasons are retuning, filter changes etc.
The chosen MINOR version stays active until changed by the user. All MINOR versions variant of
an instrument must be available in all future file-releases.
PATCH version levels are just increased, as they are defined to not change the sound outcome.
For example they fix obvious bugs nobody could have wanted, extend the range of an instrument
or introduce new CC controlers for parameters previously not available.
PATCH versions are automatically upgraded. You cannot go back programatically.
The PATCH number is not included in the sfz file name, while major and minor are.
A MAJOR version must be an entirely different file. These are incompatible with older versions.
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.
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
@ -120,7 +87,7 @@ class Instrument(object):
self.rootPrefixPath = ""
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.numberOfOutputsPairs:int = int(self.metadata["outputPairs"]) if "outputPairs" in self.metadata else 1
self.currentVariantKeySwitches = None #set by _parseKeys through chooseVariant . Ttuple: dict, sw_lokey, sw_highkey . See docstring of parseKeySwitches
self.currentKeySwitch:int = None # Midi pitch. Default is set on load.
@ -129,6 +96,15 @@ class Instrument(object):
self.controlLabels = {} #CC int:str opcode label_cc# in <control>
self.keyLabels = {} #Pitch int:str opcode label_key# in <control>
self.outputLabels = {} # self._parseKeyInfoAndLabels()
self.audioOutputs = [] #jack audio output ports uuids, compatible with cbox. Multiple of 2 because stereo pairs. They get created on enable and deleted on disable. Between these points they are static. All variants have the same number of outputs.
#Set in self.enable()
self.outputMergerRouters = [] #use index as slot index
self.routerToGlobalSummingStereoMixers = [] #use index as slot index
self.monoOutputPortsNames = [] #without jack client name. Empty if not enabled
#We could call self.loadSamples() now, but we delay that for the user experience. See docstring.
def exportStatus(self)->dict:
"""The call-often function to get the instrument status. Includes only data that can
@ -150,6 +126,7 @@ class Instrument(object):
result["playableKeys"] = self.playableKeys
result["keyLabels"] = self.keyLabels
result["controlLabels"] = self.controlLabels #CCs
result["outputLabels"] = self.outputLabels
return result
def exportMetadata(self)->dict:
@ -221,7 +198,7 @@ class Instrument(object):
if not variantSfzFileName in self.variants:
raise ValueError("Variant not in list: {} {}".format(variantSfzFileName, self.variants))"Start loading samples for instrument {variantSfzFileName} with id key {self.idKey}")"Start loading instrument variant {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.variants.index(variantSfzFileName)
@ -253,7 +230,25 @@ class Instrument(object):
self.currentKeySwitch = None"Finished loading samples for instrument {variantSfzFileName} with id key {self.idKey}")
#Set pretty names for out jack audio outputs
#If there is an output label we use that instead of the pair-number
#If there are no labels but just one pair (most instruments) we remove the number
#For multi-output instruments we use a label were present, and fall back to a number if not.
if self.numberOfOutputsPairs == 1 and not 0 in self.outputLabels:
assert not self.outputLabels, self.outputLabels
n = 0
cbox.JackIO.Metadata.set_pretty_name(self.cboxPortname+"_"+str(n)+"_L", self.midiInputPortName+" L")
cbox.JackIO.Metadata.set_pretty_name(self.cboxPortname+"_"+str(n)+"_R", self.midiInputPortName+" R")
for outputNum in range(self.numberOfOutputsPairs):
if outputNum in self.outputLabels: #dict
#print (self.outputLabels[outputNum], outputNum)
cbox.JackIO.Metadata.set_pretty_name(self.cboxPortname+"_"+str(outputNum)+"_L", self.midiInputPortName+ " " + self.outputLabels[outputNum] + " L")
cbox.JackIO.Metadata.set_pretty_name(self.cboxPortname+"_"+str(outputNum)+"_R", self.midiInputPortName+ " " + self.outputLabels[outputNum] + " R")
# print ("no label", outputNum)"Finished loading instrument variant {variantSfzFileName} with id key {self.idKey}")
def _parseKeyInfoAndLabels(self):
@ -361,10 +356,16 @@ class Instrument(object):
for notePitch in range(lower, higher+1):
def findOutputPairs(data:dict, writeInResult:set):
if "output" in data:
writeInResult.add(int(data["output"]))"Start parsing possible keyswitches in the current variant/cbox-program for {} {self.currentVariant}")
result = {} # int:tuple(opcode, keyswitch-label)
others = {} # var:var
outputPairsResultSet = set()
hierarchy = self.program.get_hierarchy() #starts with global and dicts down with get_children(). First single entry layer is get_global()
allKeys = set()
@ -377,22 +378,26 @@ class Instrument(object):
k1AsDict = k1.as_dict()
findPlayableKeys(k1AsDict, allKeys)
findKS(k1AsDict, result, others)
findOutputPairs(k1AsDict, outputPairsResultSet)
if v1:
for k2,v2 in v1.items(): #Group
k2AsDict = k2.as_dict()
findPlayableKeys(k2AsDict, allKeys)
findKS(k2AsDict, result, others)
findOutputPairs(k2AsDict, outputPairsResultSet)
if v2:
for k3,v3 in v2.items(): #Regions
k3AsDict = k3.as_dict()
findPlayableKeys(k3AsDict, allKeys)
findKS(k3AsDict, result, others)
findOutputPairs(k3AsDict, outputPairsResultSet)
#Setup labels and string descriptions, most of which will be used when actually loading the instrument
self.playableKeys = tuple(sorted(allKeys))
self.controlLabels = self.program.get_control_labels() #opcode label_cc# in <control>
self.outputLabels = self.program.get_output_labels() #opcode label_output# in <control>
self.keyLabels = self.program.get_key_labels() #opcode label_cc# in <control>
#Add some defaults.
#Add some default key labels
for k,v in {60:"Middle C", 53:"𝄢", 67:"𝄞"}.items():
if not k in self.keyLabels:
self.keyLabels[k] = v
@ -459,17 +464,21 @@ class Instrument(object):
return changed, new
def enable(self):
"""While the instrument ini was already parsed on program start we only create
the jack port and load samples when requested.
Creating the jack ports takes a non-trivial amount of time, which produces an unacceptably
slow startup.
At this point there is no knowledge about any of the sfz variants of this instrument,
we only know the ini metadata. For example we don't know anything about keyswitches or
output pairs number and labels.
After this step an instrument variant must still be loaded. The api and GUI combine this
process by auto-loading the standard variant.
""""Start enabling instrument {self.midiInputPortName}.")
if self.enabled:
raise RuntimeError(f"{} tried to switch to enabled, but it already was. This is not a trivial error. Please make sure no user-function can reach this state.")
@ -478,49 +487,95 @@ class Instrument(object):
#Calfbox. The JACK ports are constructed without samples at first.
self.scene = cbox.Document.get_engine().new_scene() #We need an individual scene for each instrument. Midi Routing is based on scenes.
self.sfzSamplerLayer = self.scene.add_new_instrument_layer(self.midiInputPortName, "sampler") #"sampler" is the cbox sfz engine
#We set temporary config settings before creating the instrument.
#In the past with only stereo outputs and self.scene.add_new_instrument_layer this was not needed
#but now we want multi outputs and need this little work around
instrumentName = str(self.idKey) #the instrument name is not visible anywhere. It is an internal name only.
cbox.Config.set("instrument:" + instrumentName, "engine", "sampler")
cbox.Config.set("instrument:" + instrumentName , "output_pairs", self.numberOfOutputsPairs)
self.instrumentLayer = self.scene.add_instrument_layer(instrumentName).get_instrument()
instrument = self.instrumentLayer
#self.sfzSamplerLayer = self.scene.add_new_instrument_layer(self.midiInputPortName, "sampler") #"sampler" is the cbox sfz engine
self.scene.status().layers[0].get_instrument().engine.load_patch_from_string(0, "", "", "") #fill with null instruments
self.instrumentLayer = self.scene.status().layers[0].get_instrument()
#self.instrumentLayer = self.scene.status().layers[0].get_instrument()
self.program = None #return object from self.instrumentLayer.engine.load_patch_from_tar
#self.scene.status().layers[0].set_ignore_program_changes(1) #TODO: ignore different channels. We only want one channel per scene/instrument/port. #TODO: Add generic filters to filter out redundant tasks like mixing and panning, which should be done in an audio mixer.
#Create Stereo Audio Ouput Ports
#Connect to our own pair but also to a generic mixer port that is in Data()
self.jackAudioOutLeft = cbox.JackIO.create_audio_output(self.midiInputPortName+"_L")
self.jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R")
self.outputMergerRouter = cbox.JackIO.create_audio_output_router(self.jackAudioOutLeft, self.jackAudioOutRight)
instrument = self.sfzSamplerLayer.get_instrument()
instrument.get_output_slot(0).rec_wet.attach(self.outputMergerRouter) #output_slot is 0 based and means a pair. Most sfz instrument have only one stereo pair. #TODO: And what if not?
#Create Stereo Audio Ouput Ports
self.routerToGlobalSummingStereoMixer = cbox.JackIO.create_audio_output_router(self.parentLibrary.parentData.lmixUuid, self.parentLibrary.parentData.rmixUuid)
#We will create the audio outputs now. They will get pretty names in chooseVariant.
#Most instruments are stereo, they have one output pair
#They are always stereo pairs, first L then R.
#Both SFZ and cbox outputs are index 0 based.
self.outputMergerRouters = [] #use index as slot index
self.routerToGlobalSummingStereoMixers = [] #use index as slot index
self.monoOutputPortsNames = [] #without jack client name
for n in range(self.numberOfOutputsPairs):
#Create two ports per output-pair. They get generic names based on the midi input and a number.
#Pretty names with output-labels or simplifications (no number for just one stereo pair) are set in ChooseVariant
outPortL = cbox.JackIO.create_audio_output(self.midiInputPortName+"_"+str(n)+"_L")
outPortR = cbox.JackIO.create_audio_output(self.midiInputPortName+"_"+ str(n)+"_R")
outputMergerRouter = cbox.JackIO.create_audio_output_router(outPortL, outPortR)
instrument.get_output_slot(n).rec_wet.attach(outputMergerRouter) #output_slot is 0 based and means a pair.
globalSumMerger = cbox.JackIO.create_audio_output_router(self.parentLibrary.parentData.lmixUuid, self.parentLibrary.parentData.rmixUuid)
#instrument.get_output_slot(n).rec_wet.attach(globalSumMerger) #this happens in setMixerEnabled
#self.jackAudioOutLeft = cbox.JackIO.create_audio_output(self.midiInputPortName+"_L")
#self.jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R")
#self.outputMergerRouter = cbox.JackIO.create_audio_output_router(self.jackAudioOutLeft, self.jackAudioOutRight)
#instrument.get_output_slot(0).rec_wet.attach(self.outputMergerRouter) #output_slot is 0 based and means a pair.
#Create Midi Input Port
self.cboxMidiPortUid = cbox.JackIO.create_midi_input(self.midiInputPortName)
cbox.JackIO.set_appsink_for_midi_input(self.cboxMidiPortUid, True) #This sounds like a program wide sink, but it is needed for every port.
cbox.JackIO.route_midi_input(self.cboxMidiPortUid, self.scene.uuid) #Route midi input to the scene. Without this we have no sound, but the python processor would still work.
self.cboxPortname = cbox.JackIO.status().client_name + ":" + self.midiInputPortName
self.midiProcessor = MidiProcessor(parentInput = self) #works through self.cboxMidiPortUid
self.parentLibrary.parentData.updateJackMetadataSorting()"Finished enabling instrument {self.midiInputPortName}. Loading a variant comes next")
def mixerLevel(self)->float:
"""We do have a list of router-mixers, but they all have the same gain value.
we just return the first one here"""
if self.enabled:
return self.routerToGlobalSummingStereoMixer.status().gain
return self.routerToGlobalSummingStereoMixers[0].status().gain
return None
@ -529,12 +584,16 @@ class Instrument(object):
"""0 is the default instrument level, as the sample files were recorded.
Negative numbers reduce volume, as it is custom in digital audio.
Default is -3.0.
Default is -1.0.
To completely mute use self.mute = True. The mixerLevel will be preserved over this-
All router-mixers are set to the same level. We receive one and apply it to the list.
if self.enabled:
for router in self.routerToGlobalSummingStereoMixers:
raise ValueError("Tried to set mixer level while instrument is disabled")
@ -548,15 +607,20 @@ class Instrument(object):
If it is None the instrument is currently not loaded. Either because it was deactivated or
because it was never loaded.
instrument = self.sfzSamplerLayer.get_instrument()
instrument = self.instrumentLayer
self.mixerEnabled = state
if state:
except: #"Router already attached"
for outputSlot, summingRouter in zip(instrument.output_slots, self.routerToGlobalSummingStereoMixers):
#output_slot means a pair. Most sfz instrument have only one stereo pair.
if state:
#but don't delete
except Exception as e: #"Router already attached" or " Recorder is not attached to this source"
#print (e)
def triggerNoteOnCallback(self, timestamp, channel, pitch, velocity):
"""args are: timestamp, channel, note, velocity.
@ -613,30 +677,35 @@ class Instrument(object):
self.scene.status().layers[0].get_instrument().engine.load_patch_from_string(0, "", "", "") #fill with null instruments, hopefully replacing the loaded sfz data.
instrument = self.sfzSamplerLayer.get_instrument()
instrument.get_output_slot(0).rec_wet.detach(self.outputMergerRouter) #output_slot is 0 based and means a pair. Most sfz instrument have only one stereo pair. #TODO: And what if not?
self.setMixerEnabled(False) # instrument.get_output_slot(0).rec_wet.detach(self.routerToGlobalSummingStereoMixer)
self.routerToGlobalSummingStereoMixer = None
self.outputMergerRouter = None
instrument = self.instrumentLayer
for router in self.routerToGlobalSummingStereoMixers:
self.routerToGlobalSummingStereoMixers = []
for outputSlot, outputMerger in zip(instrument.output_slots, self.outputMergerRouters):
outputSlot.rec_wet.detach(outputMerger) #output_slot means a pair. Most sfz instrument have only one stereo pair.
except: #"Recorder is not attached to this source"
self.outputMergerRouters = [] #use index as slot index
for audioOutput in self.audioOutputs:
self.audioOutputs = []
self.setMixerEnabled(False) # Already deleted. Just in case?
self.scene = None
self.sfzSamplerLayer = None
self.cboxMidiPortUid = None
self.instrumentLayer = None
self.program = None
self.enabled = False
self.jackAudioOutLeft = None
self.jackAudioOutRight = None
self.currentVariant = ""
self.midiProcessor = None
self.mixerEnabled = None #not only is the mixer disabled, but it is unavailable.


@ -65,7 +65,7 @@ class Data(TemplateData):
self.cachedSerializedDataForStartEngine = None
def allInstr(self):
for libId, lib in self.libraries.items():
for libId, lib in sorted(self.libraries.items()):
for instrId, instr in lib.instruments.items():
yield instr
@ -189,8 +189,8 @@ class Data(TemplateData):
the Auditioner.
self.lmixUuid = cbox.JackIO.create_audio_output('left_mix')
self.rmixUuid = cbox.JackIO.create_audio_output('right_mix')
self.lmixUuid = cbox.JackIO.create_audio_output('Stereo Mix L')
self.rmixUuid = cbox.JackIO.create_audio_output('Stereo Mix R')
self.auditioner = Auditioner(self)
@ -199,8 +199,8 @@ class Data(TemplateData):
hardwareAudioPorts = cbox.JackIO.get_ports("system*", cbox.JackIO.AUDIO_TYPE, cbox.JackIO.PORT_IS_SINK | cbox.JackIO.PORT_IS_PHYSICAL) #don't sort. This is correctly sorted. Another sorted will do 1, 10, 11,
clientName = cbox.JackIO.status().client_name
mix_l = f"{clientName}:left_mix"
mix_r = f"{clientName}:right_mix"
mix_l = f"{clientName}:Stereo Mix L"
mix_r = f"{clientName}:Stereo Mix R"
aud_l = f"{clientName}:{self.auditioner.midiInputPortName}_L"
aud_r = f"{clientName}:{self.auditioner.midiInputPortName}_R"
@ -236,17 +236,25 @@ class Data(TemplateData):
clientName = cbox.JackIO.status().client_name
order = {}
orderCounter = 0
for instr in self.allInstr():
L = clientName + ":" + instr.midiInputPortName + "_L"
R = clientName + ":" + instr.midiInputPortName + "_R"
order[L] = (orderCounter, instr)
orderCounter += 1
order[R] = (orderCounter, instr)
orderCounter += 1
for instr in self.allInstr(): #this is sorted alphabetically, which is the library id
#Use the real names, not the pretty metadata names
#We save the instrument object so that the real function updateJackMetadataSorting below can quickly check if an instrument is currently enabled and send this to jack metadata, or not
for n in range(instr.numberOfOutputsPairs):
L = clientName + ":" + instr.midiInputPortName+"_"+str(n)+"_L"
order[L] = (orderCounter, instr)
orderCounter += 1
R = clientName + ":" + instr.midiInputPortName+"_"+str(n)+"_R"
order[R] = (orderCounter, instr)
orderCounter += 1
#Also send the midi portname. It doesn't matter that they all get very high numbers.
#Sure, we could do a midi counter as well, but it just works fine this way. The order matters, not small numbers.
order[clientName + ":" + instr.midiInputPortName] = (orderCounter, instr) #midi port
orderCounter +=1
#print (3* len(list(self.allInstr()))) #without multi-out instruments this is the orderCounter in the end.
self._cachedJackMedataPortOrder = order
def updateJackMetadataSorting(self):
@ -256,11 +264,29 @@ class Data(TemplateData):
jack ports.
Luckily our data never changes. We can just prepare one order, cache it, filter it
and send that again and again.
and send that again and again when instruments get disabled and enabled.
cleanedOrder = { fullportname : index for fullportname, (index, instrObj) in self._cachedJackMedataPortOrder.items() if instrObj.enabled}"Calculating new port sorting/order based on permanent general list and currently enabled instruments")
#Add static ports
clientName = cbox.JackIO.status().client_name
mix_l = f"{clientName}:Stereo Mix L"
mix_r = f"{clientName}:Stereo Mix R"
aud_midi = f"{clientName}:{self.auditioner.midiInputPortName}"
aud_l = f"{clientName}:{self.auditioner.midiInputPortName}_L"
aud_r = f"{clientName}:{self.auditioner.midiInputPortName}_R"
staticOrder = {}
staticOrder[mix_l] = 0
staticOrder[mix_r] = 1
staticOrder[aud_l] = 2
staticOrder[aud_r] = 3
staticOrder[aud_midi] = 4
offset = len(staticOrder.keys())
#Now the dynamic ports with static offset
cleanedOrder = { fullportname : index+offset for fullportname, (index, instrObj) in self._cachedJackMedataPortOrder.items() if instrObj.enabled}
cbox.JackIO.Metadata.set_all_port_order(cleanedOrder) #wants a dict with {complete jack portname : sortIndex}
except Exception as e: #No Jack Meta Data or Error with ports.


@ -879,6 +879,7 @@ class DocInstrument(DocObj):
def move_to(self, target_scene, pos = 0):
return self.cmd_makeobj("/move_to", target_scene.uuid, pos + 1)
def get_output_slot(self, slot):
return self.output_slots[slot]
Document.classmap['cbox_instrument'] = DocInstrument
@ -1125,6 +1126,8 @@ class SamplerProgram(DocObj):
return self.get_thing("/control_labels", '/control_label', {int : str})
def get_key_labels(self):
return self.get_thing("/key_labels", '/key_label', {int : str})
def get_output_labels(self):
return self.get_thing("/output_labels", '/output_label', {int : str})
def get_keyswitch_groups(self):
return self.get_thing("/keyswitch_groups", '/key_range', [(int, int)])
def new_group(self):