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.
237 lines
12 KiB
237 lines
12 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
|
|
|
|
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
|
|
|