#! /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
#Third Party
from calfbox import cbox
#Template Modules
from template . engine . input_midi import MidiProcessor
class Instrument ( object ) :
""" Literally one instrument.
It might exists in different versions that are all loaded here and can be switched in the GUI .
For that we identify different . sfz files by a minor version number ( see below ) .
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 .
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 four 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 . Which is problematic :
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 ) """
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
self . tarFilePath = tarFilePath
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.
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.
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 [ " id-key " ] = 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.
return result
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 .
Please note that we don ' t add the default variant here. It is only important for the
external world to know what the current variant is . Which is handled by self . exportStatus ( )
"""
parentMetadata = self . parentLibrary . config [ " library " ]
result = { }
result [ " id " ] = int ( self . metadata [ " id " ] ) #int
result [ " id-key " ] = 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
#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 .
"""
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 self . enabled :
raise RuntimeError ( f " { self . name } tried to load variant { variantSfzFileName } but was not yet enabled " )
if not variantSfzFileName in self . metadata [ " variants " ] :
raise ValueError ( " Variant not in list: {} {} " . format ( variantSfzFileName , self . metadata [ " 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 . metadata [ " variants " ] . index ( variantSfzFileName ) #counts from 1
self . program = self . instrumentLayer . engine . load_patch_from_tar ( programNumber , self . tarFilePath , self . rootPrefixPath + variantSfzFileName , self . metadata [ " name " ] ) #tar_name, sfz_name, display_name
self . instrumentLayer . engine . set_patch ( 1 , programNumber ) #1 is the channel, counting from 1. #TODO: we want this to be on all channels.
#self.program is always None ?
#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)}
#Only ever index 0 is used because we have one patch per port
#self.instrumentLayer.engine.get_patches()[0][1] is the cbox.SamplerProgram. That should have been self.program, but isn't!
self . program = self . instrumentLayer . engine . get_patches ( ) [ 0 ] [ 1 ]
logger . info ( self . program . status ( ) )
self . currentVariant = variantSfzFileName
logger . info ( f " Finished loading samples for instrument { variantSfzFileName } with id key { self . idKey } " )
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 . midiProcessor = MidiProcessor ( parentInput = self ) #works through self.cboxMidiPortUid
self . midiProcessor . register_NoteOn ( self . triggerActivityCallback )
#self.midiProcessor.notePrinter(True)
self . parentLibrary . parentData . parentSession . eventLoop . slowConnect ( 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 triggerActivityCallback ( self , * args ) :
""" args are: timestamp, channel, note, velocity.
Which we all don ' t need at the moment.
If in the future we need these for a more important task than blinking an LED :
consider to change eventloop . slowConnect to fastConnect . And also disconnect in self . disable ( ) """
self . parentLibrary . parentData . instrumentMidiNoteOnActivity ( * self . idKey )
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
" 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