#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2021 , 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
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 . id = metadata [ " id " ]
self . idKey = ( libraryId , metadata [ " id " ] )
Instrument . allInstruments [ self . idKey ] = self
self . cboxMidiPortUid = None
self . metadata = metadata #parsed from the ini file. See self.exportMetadata for the pythonic dict
self . tarFilePath = tarFilePath
self . name = metadata [ " name " ]
self . midiInputPortName = metadata [ " name " ]
self . variants = metadata [ " variants " ] . split ( " , " )
self . defaultVariant = metadata [ " defaultVariant " ]
self . enabled = False #means loaded.
#Calfbox. The JACK ports are constructed without samples at first.
self . scene = cbox . Document . get_engine ( ) . new_scene ( )
self . scene . clear ( )
layer = 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()
jackAudioOutLeft = cbox . JackIO . create_audio_output ( self . midiInputPortName + " _L " )
jackAudioOutRight = cbox . JackIO . create_audio_output ( self . midiInputPortName + " _R " )
outputMergerRouter = cbox . JackIO . create_audio_output_router ( jackAudioOutLeft , jackAudioOutRight )
outputMergerRouter . set_gain ( - 3.0 )
instrument = layer . get_instrument ( )
instrument . get_output_slot ( 0 ) . rec_wet . attach ( outputMergerRouter ) #output_slot is 0 based and means a pair. Most sfz instrument have only one stereo pair. #TODO: And what if not?
routerToGlobalSummingStereoMixer = cbox . JackIO . create_audio_output_router ( self . parentLibrary . parentData . lmixUuid , self . parentLibrary . parentData . rmixUuid )
routerToGlobalSummingStereoMixer . set_gain ( - 3.0 )
instrument . get_output_slot ( 0 ) . rec_wet . attach ( routerToGlobalSummingStereoMixer )
#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 )
self . startVariantSfzFilename = startVariantSfzFilename
self . currentVariant : str = None #set by self.chooseVariant()
#We could call self.load() now, but we delay that for the user experience. See docstring.
def exportStatus ( self ) :
""" The call-often function to get the instrument status. Includes only data that can
actually change during runtime . """
result = { }
#Static ids
result [ " id " ] = self . metadata [ " id " ]
result [ " id-key " ] = self . idKey #redundancy for convenience.
#Dynamic data
result [ " currentVariant " ] = self . currentVariant # str
result [ " currentVariantWithoutSfzExtension " ] = self . currentVariant . rstrip ( " .sfz " ) if self . currentVariant else " " # str
result [ " state " ] = self . enabled #bool
return result
def exportMetadata ( self ) :
""" 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 " ] = 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
#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 ) :
""" Instrument is constructed without loading the sample data. But the JACK Port and
all Python objects exist . The API can instruct the loading when everything is ready ,
so that the callbacks can receive load - progress messages """
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 :
return
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 , variantSfzFileName , self . metadata [ " 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 . currentVariant = variantSfzFileName
logger . info ( f " Finished loading samples for instrument { variantSfzFileName } with id key { self . idKey } " )
def enable ( self ) :
self . enabled = True
def disable ( self ) :
""" Jack midi port and audio ports will disappear. Impact on RAM and CPU usage unknown. """
self . enabled = False