Sampled Instrument Player with static and monolithic design. All instruments are built-in.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

726 lines
38 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
This application is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Python Standard Lib
import re
#Template Modules
from template.calfbox import cbox
from template.engine.pitch import midiName2midiPitch #dict
from template.engine.input_midi import MidiProcessor
class Instrument(object):
"""Literally one instrument.
It might exists in different variants and version that are all loaded here and can be
switched in the GUI.
All data is provided by the parsed metadata dict, except the filepath of the tar which calfbox
needs again here.
The metadata dict contains a list of all available sfz files describing variants of the same file,
eg. sharing most of the sample data. The variants filename will be used directly as "preset"
name in a GUI etc.
The variants are case sensitive filenames ending in .sfz
The order of variants in the config file will never change, it can only get appended.
This way indexing will remain consistent over time.
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
instrument, but is this a variant? Different microphone or mic position maybe?
This is mostly important when we are not talking about upgrades, which can just use version
numbers, but true "side-grades". I guess the main argument is that you never would want both
variants at the same time. And even if the muffled blanket sound is the same instrument,
this could be integrated as CC switch or fader. Same for the different microphones and positions.
At the time of writing the author was not able to come up with a "sidegrade" usecase,
that isn't either an sfz controller or a different instrument (I personally consider the
blanket-piano a different instrument)"""
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
self.tarFilePath = tarFilePath
self.libraryId = libraryId
self.startVariantSfzFilename = startVariantSfzFilename
self.idKey = (self.libraryId, int(self.metadata["id"]))
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.
self.mixerEnabled = None #not only is the mixer disabled, but it is unavailable.
self.instrumentsInLibraryCount = None #injected by the creating process. Counted live in the program, not included in the ini file.
self.name = self.metadata["name"]
self.cboxMidiPortUid = None
self.midiInputPortName = f"[{self.libraryId}-{self.id}] " + self.metadata["name"]
self.variants = self.metadata["variants"].split(";")
self.defaultVariant = self.metadata["defaultVariant"]
if "root" in self.metadata:
if self.metadata["root"].endswith("/"):
self.rootPrefixPath = self.metadata["root"]
else:
self.rootPrefixPath = self.metadata["root"] + "/"
else:
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.
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.
self.playableKeys:tuple = None #sorted tuple of ints. set by _parseKeys through chooseVariant. Set of int pitches. Used for export.
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
actually change during runtime."""
result = {}
#Static ids
result["id"] = int(self.metadata["id"])
result["idKey"] = self.idKey #redundancy for convenience.
#Dynamic data
result["currentVariant"] = self.currentVariant # str
result["currentVariantWithoutSfzExtension"] = self.currentVariant.rstrip(".sfz") if self.currentVariant else ""# str
result["state"] = self.enabled #bool
result["mixerEnabled"] = self.mixerEnabled #bool
result["mixerLevel"] = self.mixerLevel #float.
result["keySwitches"] = self.currentVariantKeySwitches[0] if self.currentVariantKeySwitches else {} #Internally this is a tuple with [0] being a dict: Unordered!! dict with midiPitch: (opcode, label). You need the opcode to see if it is a momentary switch or permanent.
result["currentKeySwitch"] = self.currentKeySwitch
result["playableKeys"] = self.playableKeys
result["keyLabels"] = self.keyLabels
result["controlLabels"] = self.controlLabels #CCs
result["outputLabels"] = self.outputLabels
return result
def exportMetadata(self)->dict:
"""
This is the big update that sends everything to build a GUI database
"""
parentMetadata = self.parentLibrary.config["library"]
result = {}
result["id"] = int(self.metadata["id"]) #int
result["idKey"] = self.idKey # tuple (int, int) redundancy for convenience.
result["name"] = self.metadata["name"] #str
result["description"] = self.metadata["description"] #str
result["variants"] = self.variants #list of str
result["variantsWithoutSfzExtension"] = [var.rstrip(".sfz") for var in self.variants] #list of str
result["defaultVariant"] = self.metadata["defaultVariant"] #str
result["defaultVariantWithoutSfzExtension"] = self.metadata["defaultVariant"].rstrip(".sfz") #str
result["tags"] = self.metadata["tags"].split(",") # list of str
result["instrumentsInLibraryCount"] = self.instrumentsInLibraryCount # int
result["status"] = self.exportStatus() #a dict #TODO: This is a problem because metadata is only exported on program start. This could lead functions to try to check for state/enabled in here, which would be always false. This is only valid on program start/load from file
#Optional Tags.
result["group"] = self.metadata["group"] if "group" in self.metadata else "" #str
#While license replaces the library license, vendor is an addition:
if "license" in self.metadata:
result["license"] = self.metadata["license"]
else:
result["license"] = parentMetadata["license"]
if "vendor" in self.metadata:
result["vendor"] = parentMetadata["vendor"] + "\n\n" + self.metadata["vendor"]
else:
result["vendor"] = parentMetadata["vendor"]
return result
def loadSamples(self):
"""
Convenience starter. Use this.
Used for loading save files as well as manual loading
"""
if not self.enabled:
self.enable()
if self.startVariantSfzFilename:
self.chooseVariant(self.startVariantSfzFilename)
else:
self.chooseVariant(self.metadata["defaultVariant"])
def chooseVariantByIndex(self, index:int):
"""The variant list is static. Instead of a name we can just choose by index.
This is convenient for functions that choose the variant by a list index"""
variantSfzFileName = self.variants[index]
self.chooseVariant(variantSfzFileName)
def chooseVariant(self, variantSfzFileName:str):
"""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.
"""
if not variantSfzFileName: #Can happen if there is an accidental empty entry in the library.ini
logger.error(f"{self.name} with id key {self.idKey} tried to load empty sfz variant. Probably a library.ini typo.")
return
if not self.enabled:
raise RuntimeError(f"{self.name} with id key {self.idKey} tried to load variant {variantSfzFileName} but was not yet enabled")
if not variantSfzFileName in self.variants:
raise ValueError("Variant not in list: {} {}".format(variantSfzFileName, self.variants))
logger.info(f"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)
#We do NOT use the program number, that was a mistake in the past. Set programNumber to 0 so it overwrites the current file.
self.program = self.instrumentLayer.engine.load_patch_from_tar(0, self.tarFilePath, self.rootPrefixPath+variantSfzFileName, self.metadata["name"]) #program number fixed to 0, tar_name, sfz_name, display_name
#self.program = self.instrumentLayer.engine.get_patches()[0][1] workaround in the past, when load_patch_from_tar returned None by mistake.
#Turns out we do not even need set_patch.
#self.instrumentLayer.engine.set_patch(1, 0) #1 is supposed to be the channel, 0 is the program. But it listens to all 16 channels anyway.
#self.instrumentLayer is type DocInstrument
#self.instrumentLayer.engine is type SamplerEngine
#self.instrumentLayer.engine.get_patches returns a dict like
# {0: ('Harpsichord.sfz', <calfbox.cbox.SamplerProgram object at 0x7fd324226a60>, 16)}
# This is similar to program.status() which returns
# SamplerProgram:in_use=16 name=CelloEns-KS.sfz program_no=0 sample_dir=./Strings/Cello Section/pizzT/ source_file=./CelloEns-KS.sfz
# This is on program number 0 and listens on all 16 channels.
#Only ever index 0 is used because we have one patch per port
status = self.program.status()
logger.info(status)
assert 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
self.currentVariantKeySwitches = self._parseKeyInfoAndLabels()
if self.currentVariantKeySwitches and self.currentVariantKeySwitches[3]: #[3] is sw_default
self.currentKeySwitch = self.currentVariantKeySwitches[3]
else:
self.currentKeySwitch = None
#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")
else:
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")
#else:
# print ("no label", outputNum)
logger.info(f"Finished loading instrument variant {variantSfzFileName} with id key {self.idKey}")
def _parseKeyInfoAndLabels(self):
"""
Called only by chooseVariant. This is a function only for readability reasons and for the
docstring.
Returns a tuple: dict, sw_lokey, sw_highkey
dict with key=keystring e.g. c#4
and value=(opcode,label). label can be empty.
keys can only get parsed if the program is actually loaded/enabled.
Only existing keyswitches are included, not every number from 0-127.
Two special keys "sw_lokey" and "sw_hikeys" are returned and show the total range of possible
keyswitches.
This is currently a function to find the most common keyswitches, not every advanced scenario
with sw_previous for context-sensitive-regions (which isn't really a keyswitch),
nor sw_lokey, sw_hikey and multiple parallel switches per key.
Specifically it just searches for sw_last, sw_down and sw_up and assumes that any level (master,
group, region) can only use one of the three.
sw_down and sw_up are implementation-dependent. We must assume that there are instruments that
use these opcodes without specifying sw_lokey and sw_hikey. sw_last requires the range.
For these reasons we cannot do sanity-checking here. We just report all single-key keyswitches.
Finally it assumes that there is one sw_lokey and one sw_hikey in the whole file, and it
must be in global. No actual keyswitches will be in global.
For example:
VSCO Strings - Cello parses as follows:
( #Dict with all keyswitches and labels. Label can be empty string.
{'d6': ('sw_last', 'D6 Spiccato'),
'c6': ('sw_last', 'C6 Sustain Vibrato'),
'd#6': ('sw_last', 'D#6 Pizzicato'),
'c#6': ('sw_last', 'C#6 Tremolo')},
'', #sw_lokey not set
'') #sw_highkey not set
Calfbox with default settings will report keys as string,
no matter if they are entered as pitch-numbers or keystrings in the .sfz file itself.
We convert them here ourselves.
We assume there can only be one sw_default. We allow redundant entries but will just
use the last value we encounter. Logical consistency must be checked with an external tool
or process.
"""
if not self.enabled:
logger.warning(f"Something tried to set the keyswitch but this instrument {self.name} {self.currentVariant} is currently not enabled. Nothing was changed.")
return
if not self.currentVariant:
logger.warning(f"Something tried to parse keyswitches but this instrument {self.name} {self.currentVariant} currently has no variant loaded. Nothing was changed.")
return
def findKS(data, writeInResult, writeInOthers):
if "sw_label" in data:
label = data["sw_label"]
#remove leading int or key from label
mMidipitch = re.match("\d+", label)
mNotename = re.match("(c|(c#)|(db)|d|(d#)|(eb)|e|(e#)|(fb)|f|(f#)|(gb)|g|(g#)|(ab)|a|(a#)|(bb)|b|(b#))\d+", label, re.IGNORECASE)
if mMidipitch and not label[1] == "'" and not label[2] == "'": # 8' or 16' organs begin with a number as well. We sadly can't check for a space after the number so we have to check for the foot symbol: #could be None
label = label[mMidipitch.span()[1]:].lstrip() #remove number and potential leading space
elif mNotename:
label = label[mNotename.span()[1]:].lstrip() #remove notenames like C#6 and potential leading space
else:
label = ""
if "sw_default" in data:
if "sw_default" in writeInOthers and writeInOthers["sw_default"] and writeInOthers["sw_default"] != data["sw_default"]:
logger.error(f"Instrument {self.name} {self.currentVariant} has multiple different sw_default values. We will use the last one encountered. This conflict: {writeInOthers['sw_default']} vs {data['sw_default']} ")
writeInOthers["sw_default"] = data["sw_default"]
if "sw_last" in data:
midiPitch = midiName2midiPitch[data["sw_last"]]
writeInResult[midiPitch] = "sw_last", label
elif "sw_down" in data:
midiPitch = midiName2midiPitch[data["sw_down"]]
writeInResult[midiPitch] = "sw_down", label
elif "sw_up" in data:
midiPitch = midiName2midiPitch[data["sw_up"]]
writeInResult[midiPitch] = "sw_up", label
def findPlayableKeys(data:dict, writeInResult:set):
"""Playable keys can be on any level. Mostly groups and regions though."""
if "key" in data:
notePitch:int = midiName2midiPitch[data["key"]]
writeInResult.add(notePitch)
if "lokey" in data and "hikey" in data:
#Guard
if data["lokey"] == "-1" or data["hikey"] == "-1": #"lokey and hikey to -1, to prevent a region from being triggered by any keys." https://sfzformat.com/opcodes/lokey
return
lower = midiName2midiPitch[data["lokey"]]
higher = midiName2midiPitch[data["hikey"]]
if lower > higher:
logger.error(f"Instrument {self.name} {self.currentVariant} SFZ problem: lokey {lower} is higher than hikey {higher}")
return
for notePitch in range(lower, higher+1):
writeInResult.add(notePitch)
def findOutputPairs(data:dict, writeInResult:set):
if "output" in data:
writeInResult.add(int(data["output"]))
logger.info(f"Start parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}")
result = {} # int:tuple(opcode, keyswitch-label)
others = {} # var:var
outputPairsResultSet = set()
outputPairsResultSet.add(0)
hierarchy = self.program.get_hierarchy() #starts with global and dicts down with get_children(). First single entry layer is get_global()
allKeys = set()
for k,v in hierarchy.items(): #Global
globalData = k.as_dict()
swlokeyValue = globalData["sw_lokey"] if "sw_lokey" in globalData else ""
swhikeyValue = globalData["sw_hikey"] if "sw_hikey" in globalData else ""
others["sw_default"] = globalData["sw_default"] if "sw_default" in globalData else ""
for k1,v1 in v.items(): #Master
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 default key labels
for k,v in {60:"Middle C", 53:"𝄢", 67:"𝄞"}.items():
if not k in self.keyLabels:
self.keyLabels[k] = v
logger.info(f"Finished parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}. Found: {len(result)} keyswitches.")
if not result:
return None
else:
return (result, swlokeyValue, swhikeyValue, midiName2midiPitch[others["sw_default"]])
def setKeySwitch(self, keySwitchMidiPitch:int):
"""Set the current variant to it's keySwitch number keySwitchIndex.
We use the key-midi-pitch directly.
We also check against our own parsing on startup if the variant has this keyswitch and
do a logger warning if the keyswitch does not exist, and then do nothing.
Keyswitches get internally reset on variant-switching and use the one specified as default
in the variant-.sfz.
"""
if not self.enabled:
logger.warning(f"Tried to set the keyswitch but this instrument {self.name} is currently not enabled. Nothing was changed.")
return
if not self.currentVariant:
logger.warning(f"Tried to parse keyswitches but this instrument {self.name} currently has no variant loaded. Nothing was changed.")
return
cboxReportedKeySwitchesRange = self.program.get_keyswitch_groups()[0] # This is ALWAYS two values. It is a range!
keySwitchDict = self.currentVariantKeySwitches[0]
if not keySwitchMidiPitch in keySwitchDict:
logger.warning(f"Tried setting instrument {self.name} to key switch {keySwitchMidiPitch} but that switch was not parsed on load. Nothing was changed. We parsed: {keySwitchDict} in the range from/to {cboxReportedKeySwitchesRange}")
return
self.scene.send_midi_event(0x90, keySwitchMidiPitch, 64) #note on with vel 64
currentKeySwitch = self.instrumentLayer.engine.get_keyswitch_state(1, 0) #midi channel indexFrom1, keyswitch group
#Confirm that we changed the switch for both development and release version
assert currentKeySwitch == keySwitchMidiPitch, (currentKeySwitch, keySwitchMidiPitch)
if not currentKeySwitch == keySwitchMidiPitch:
logger.error(f"Tried setting instrument {self.name} to key switch {keySwitchMidiPitch} but afterwards we are switch {currentKeySwitch}. Cause unknown. Not sure if this is a problem or not.")
return self.updateCurrentKeySwitch()
def updateCurrentKeySwitch(self, force=None):
"""This is either called directly by setKeySwitch after a user change
but also from our midi event checker in python.
Returns a tuple (stateChangedSinceLastCheck:bool, keySwitchMidiPitch)
This is called on every(!) note on.
This is only for permanent sw_last, not momentary sw_up and sw_down
"""
if not self.currentVariantKeySwitches or not self.instrumentLayer:
return #optimisation.
if not force is None:
changed = self.currentKeySwitch != force
new = force
else:
new = self.instrumentLayer.engine.get_keyswitch_state(1, 0) #midi channel indexFrom1, keyswitch group(!! ugly !!. We don't support that at all.)
changed = self.currentKeySwitch != new
self.currentKeySwitch = new
return changed, new
def enable(self):
"""While the instrument ini was already parsed on program start we only create
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.
"""
logger.info(f"Start enabling instrument {self.midiInputPortName}.")
if self.enabled:
raise RuntimeError(f"{self.name} 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.")
self.enabled = True
#Calfbox. The JACK ports are constructed without samples at first.
2 years ago
self.scene = cbox.Document.get_engine().new_scene() #We need an individual scene for each instrument. Midi Routing is based on scenes.
self.scene.clear()
#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.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.
#self.instrumentLayer.engine.set_polyphony(int)
#Create Stereo Audio Ouput Ports
self.routerToGlobalSummingStereoMixer = cbox.JackIO.create_audio_output_router(self.parentLibrary.parentData.lmixUuid, self.parentLibrary.parentData.rmixUuid)
self.routerToGlobalSummingStereoMixer.set_gain(-1.0)
#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")
self.monoOutputPortsNames.append(outPortL)
self.monoOutputPortsNames.append(outPortR)
self.audioOutputs.append(outPortL)
self.audioOutputs.append(outPortR)
outputMergerRouter = cbox.JackIO.create_audio_output_router(outPortL, outPortR)
outputMergerRouter.set_gain(-1.0)
instrument.get_output_slot(n).rec_wet.attach(outputMergerRouter) #output_slot is 0 based and means a pair.
self.outputMergerRouters.append(outputMergerRouter)
globalSumMerger = cbox.JackIO.create_audio_output_router(self.parentLibrary.parentData.lmixUuid, self.parentLibrary.parentData.rmixUuid)
globalSumMerger.set_gain(-1.0)
#instrument.get_output_slot(n).rec_wet.attach(globalSumMerger) #this happens in setMixerEnabled
self.routerToGlobalSummingStereoMixers.append(globalSumMerger)
#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)
#self.outputMergerRouter.set_gain(-1.0)
#instrument.get_output_slot(0).rec_wet.attach(self.outputMergerRouter) #output_slot is 0 based and means a pair.
self.setMixerEnabled(True)
#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.midiProcessor.register_NoteOn(self.triggerNoteOnCallback)
self.midiProcessor.register_NoteOff(self.triggerNoteOffCallback)
self.midiProcessor.register_CC(self.triggerCCCallback)
#self.midiProcessor.notePrinter(True)
self.parentLibrary.parentData.parentSession.eventLoop.fastConnect(self.midiProcessor.processEvents)
self.parentLibrary.parentData.updateJackMetadataSorting()
logger.info(f"Finished enabling instrument {self.midiInputPortName}. Loading a variant comes next")
@property
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.routerToGlobalSummingStereoMixers[0].status().gain
else:
return None
@mixerLevel.setter
def mixerLevel(self, value:float):
"""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 -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:
router.set_gain(value)
else:
raise ValueError("Tried to set mixer level while instrument is disabled")
def setMixerEnabled(self, state:bool):
"""Connect or disconnect the instrument from the summing mixer.
We need to track the connection state on our own.
The mixer level is preserved.
self.mixerEnabled can be True or False, this is always a user value.
If it is None the instrument is currently not loaded. Either because it was deactivated or
because it was never loaded.
"""
instrument = self.instrumentLayer
self.mixerEnabled = state
for outputSlot, summingRouter in zip(instrument.output_slots, self.routerToGlobalSummingStereoMixers):
try:
#output_slot means a pair. Most sfz instrument have only one stereo pair.
if state:
outputSlot.rec_wet.attach(summingRouter)
else:
outputSlot.rec_wet.detach(summingRouter)
#but don't delete
except Exception as e: #"Router already attached" or " Recorder is not attached to this source"
#print (e)
pass
def triggerNoteOnCallback(self, timestamp, channel, pitch, velocity):
"""args are: timestamp, channel, note, velocity.
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
self.parentLibrary.parentData.instrumentMidiNoteOnActivity(self.idKey, pitch, velocity)
def triggerNoteOffCallback(self, timestamp, channel, pitch, velocity):
"""args are: timestamp, channel, note, velocity.
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
self.parentLibrary.parentData.instrumentMidiNoteOffActivity(self.idKey, pitch, velocity)
def triggerCCCallback(self, timestamp, channel, ccNumber, value):
"""args are: timestamp, channel, ccNumber, value
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
self.parentLibrary.parentData.instrumentCCActivity(self.idKey, ccNumber, value)
def getAvailablePorts(self)->dict:
"""This function queries JACK each time it is called.
It returns a dict with two lists.
Keys "hardware" and "software" for the type of port.
"""
result = {}
hardware = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE | cbox.JackIO.PORT_IS_PHYSICAL))
allPorts = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE))
software = allPorts.difference(hardware)
result["hardware"] = sorted(list(hardware))
result["software"] = sorted(list(software))
return result
def connectMidiInputPort(self, externalPort:str):
"""externalPort is in the Client:Port JACK format
If "" False or None disconnect all ports."""
try:
currentConnectedList = cbox.JackIO.get_connected_ports(self.cboxMidiPortUid)
except: #port not found.
currentConnectedList = []
for port in currentConnectedList:
cbox.JackIO.port_disconnect(port, self.cboxPortname)
if externalPort:
availablePorts = self.getAvailablePorts()
if not (externalPort in availablePorts["hardware"] or externalPort in availablePorts["software"]):
raise RuntimeError(f"Instrument was instructed to connect to port {externalPort}, which does not exist")
cbox.JackIO.port_connect(externalPort, self.cboxPortname)
def disable(self):
"""Jack midi port and audio ports will disappear. Impact on RAM and CPU usage unknown."""
if not self.enabled:
raise RuntimeError(f"{self.name} tried to switch to disabled, but it already was. This is not a trivial error. Please make sure no user-function can reach this state.")
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.instrumentLayer
for router in self.routerToGlobalSummingStereoMixers:
router.delete()
self.routerToGlobalSummingStereoMixers = []
for outputSlot, outputMerger in zip(instrument.output_slots, self.outputMergerRouters):
try:
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"
pass
outputMerger.delete()
self.outputMergerRouters = [] #use index as slot index
for audioOutput in self.audioOutputs:
cbox.JackIO.delete_audio_output(audioOutput)
self.audioOutputs = []
cbox.JackIO.delete_midi_input(self.cboxMidiPortUid)
self.setMixerEnabled(False) # Already deleted. Just in case?
self.scene.clear()
self.parentLibrary.parentData.parentSession.eventLoop.slowDisconnect(self.midiProcessor.processEvents)
self.scene = None
self.cboxMidiPortUid = None
self.instrumentLayer = None
self.program = None
self.enabled = False
self.currentVariant = ""
self.midiProcessor = None
self.mixerEnabled = None #not only is the mixer disabled, but it is unavailable.
self.parentLibrary.parentData.updateJackMetadataSorting()
#Save
def serialize(self)->dict:
return {
"id" : self.id, #for convenience access
"currentVariant" : self.currentVariant, #string. Since currentVariant is set to "" when disabling an instrument this is also our marker for the instrument loaded state.
"mixerLevel" : self.mixerLevel, #float
"mixerEnabled" : self.mixerEnabled, #bool
#Do NOT save "self.enabled". This is just an internal convenience switch. currentVariant is the data that tells us if there was an actively loaded instrument, inluding loaded samples.
}
#Loading is done externally by main/Data directly