#! /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 . """ 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', , 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