#! /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 .
NOTE CONCERNING THE BELOW :
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 .
Examples :
SalamanderPiano1 .2 . sfz
SalamanderPiano1 .3 . sfz
SalamanderPiano1 .6 . sfz
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
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.
#We could call self.loadSamples() now, but we delay that for the user experience. See docstring.
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>
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
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 samples for instrument { variantSfzFileName } with id key { self . idKey } " )
#help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc.
#newProgramNumber = self.instrumentLayer.engine.get_unused_program()
#programNumber = self.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
logger . info ( f " Finished loading samples for instrument { 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 )
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
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 )
if v1 :
for k2 , v2 in v1 . items ( ) : #Group
k2AsDict = k2 . as_dict ( )
findPlayableKeys ( k2AsDict , allKeys )
findKS ( k2AsDict , result , others )
if v2 :
for k3 , v3 in v2 . items ( ) : #Regions
k3AsDict = k3 . as_dict ( )
findPlayableKeys ( k3AsDict , allKeys )
findKS ( k3AsDict , result , others )
self . playableKeys = tuple ( sorted ( allKeys ) )
self . controlLabels = self . program . get_control_labels ( ) #opcode label_cc# in <control>
self . keyLabels = self . program . get_key_labels ( ) #opcode label_cc# in <control>
#Add some defaults.
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 .
After this step an instrument variant must still be loaded . The api and GUI combine this
process by auto - loading the standard variant .
"""
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.
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 ( )
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
#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 )
self . outputMergerRouter . set_gain ( - 3.0 )
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?
self . routerToGlobalSummingStereoMixer = cbox . JackIO . create_audio_output_router ( self . parentLibrary . parentData . lmixUuid , self . parentLibrary . parentData . rmixUuid )
self . routerToGlobalSummingStereoMixer . set_gain ( - 3.0 )
instrument . get_output_slot ( 0 ) . rec_wet . attach ( self . routerToGlobalSummingStereoMixer )
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 ( )
@property
def mixerLevel ( self ) - > float :
if self . enabled :
return self . routerToGlobalSummingStereoMixer . 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 - 3.0 .
To completely mute use self . mute = True . The mixerLevel will be preserved over this -
"""
if self . enabled :
self . routerToGlobalSummingStereoMixer . 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 . sfzSamplerLayer . get_instrument ( )
self . mixerEnabled = state
try :
if state :
instrument . get_output_slot ( 0 ) . rec_wet . attach ( self . routerToGlobalSummingStereoMixer )
else :
instrument . get_output_slot ( 0 ) . rec_wet . detach ( self . routerToGlobalSummingStereoMixer )
except : #"Router already attached"
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 . 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 . delete ( )
self . outputMergerRouter . delete ( )
self . routerToGlobalSummingStereoMixer = None
self . outputMergerRouter = None
self . scene . clear ( )
cbox . JackIO . delete_audio_output ( self . jackAudioOutLeft )
cbox . JackIO . delete_audio_output ( self . jackAudioOutRight )
cbox . JackIO . delete_midi_input ( self . cboxMidiPortUid )
self . parentLibrary . parentData . parentSession . eventLoop . slowDisconnect ( self . midiProcessor . processEvents )
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.
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