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.
374 lines
19 KiB
374 lines
19 KiB
#! /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
|
|
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()
|
|
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
|
|
|