Nils
4 years ago
11 changed files with 861 additions and 16151 deletions
@ -0,0 +1,143 @@ |
|||
#! /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 Auditioner(object): |
|||
""" |
|||
A special instrument class. |
|||
|
|||
Has access to all libraries and can quickly change its sounds. |
|||
It has one midi input and stereo output. |
|||
Music output does not contribute to global mixer summing ports. |
|||
|
|||
This is another instance of the instrument library. It will not touch any CC settings |
|||
or filters of the real instrument, because there is a chance that this will change the sound |
|||
of the real instrument by accident. |
|||
""" |
|||
|
|||
def __init__(self, parentData): |
|||
self.parentData = parentData |
|||
self.cboxMidiPortUid = None |
|||
self.midiInputPortName = "Auditioner" |
|||
self.cboxPortname = cbox.JackIO.status().client_name + ":" + self.midiInputPortName |
|||
|
|||
#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() |
|||
if self.parentData.parentSession.standaloneMode: |
|||
jackAudioOutLeft = cbox.JackIO.create_audio_output(self.midiInputPortName+"_L", "#1") #add "#1" as second parameter for auto-connection to system out 1 |
|||
jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R", "#2") #add "#1" as second parameter for auto-connection to system out 2 |
|||
else: |
|||
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? |
|||
|
|||
#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) |
|||
|
|||
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["state"] = self.enabled #bool |
|||
|
|||
return result |
|||
|
|||
def loadInstrument(self, tarFilePath, 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. |
|||
""" |
|||
logger.info(f"Start loading samples for auditioner {variantSfzFileName}") |
|||
#help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc. |
|||
#newProgramNumber = self.instrumentLayer.engine.get_unused_program() |
|||
programNumber = 1 |
|||
name = variantSfzFileName |
|||
self.program = self.instrumentLayer.engine.load_patch_from_tar(programNumber, tarFilePath, variantSfzFileName, 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 auditioner {variantSfzFileName}") |
|||
|
|||
|
|||
def getAvailablePorts(self)->dict: |
|||
"""This function queries JACK each time it is called. |
|||
It returns a dict with two lists. |
|||
Keys "hardware" and "software" for the type of port. |
|||
""" |
|||
result = {} |
|||
hardware = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE | cbox.JackIO.PORT_IS_PHYSICAL)) |
|||
allPorts = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE)) |
|||
software = allPorts.difference(hardware) |
|||
result["hardware"] = sorted(list(hardware)) |
|||
result["software"] = sorted(list(software)) |
|||
return result |
|||
|
|||
|
|||
def connectMidiInputPort(self, externalPort:str): |
|||
"""externalPort is in the Client:Port JACK format |
|||
If "" False or None disconnect all ports.""" |
|||
|
|||
try: |
|||
currentConnectedList = cbox.JackIO.get_connected_ports(self.cboxMidiPortUid) |
|||
except: #port not found. |
|||
currentConnectedList = [] |
|||
|
|||
for port in currentConnectedList: |
|||
cbox.JackIO.port_disconnect(port, self.cboxPortname) |
|||
|
|||
if externalPort: |
|||
availablePorts = self.getAvailablePorts() |
|||
if not (externalPort in availablePorts["hardware"] or externalPort in availablePorts["software"]): |
|||
raise RuntimeError(f"Auditioner was instructed to connect to port {externalPort}, which does not exist") |
|||
|
|||
cbox.JackIO.port_connect(externalPort, self.cboxPortname) |
@ -0,0 +1,214 @@ |
|||
#! /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 = True #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["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() |
|||
""" |
|||
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["variants"] = self.variants #list of str |
|||
result["defaultVariant"] = self.metadata["defaultVariant"] #str |
|||
result["tags"] = self.metadata["tags"].split(",") # list of str |
|||
|
|||
#Optional Tags. |
|||
result["license"] = self.metadata["license"] if "license" in self.metadata else "" #str |
|||
result["vendor"] = self.metadata["vendor"] if "vendor" in self.metadata else "" #str |
|||
result["group"] = self.metadata["group"] if "group" in self.metadata else "" #str |
|||
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""" |
|||
if self.startVariantSfzFilename: |
|||
self.chooseVariant(self.startVariantSfzFilename) |
|||
else: |
|||
self.chooseVariant(self.metadata["defaultVariant"]) |
|||
|
|||
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 |
File diff suppressed because it is too large
Loading…
Reference in new issue