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.
656 lines
34 KiB
656 lines
34 KiB
#! /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 <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
|
|
import logging; logger = logging.getLogger(__name__); logger.info("import")
|
|
|
|
#Python Standard Lib
|
|
import re
|
|
|
|
#Template Modules
|
|
from template.calfbox import cbox
|
|
from template.engine.pitch import midiName2midiPitch #dict
|
|
from template.engine.input_midi import MidiProcessor
|
|
|
|
|
|
class Instrument(object):
|
|
"""Literally one instrument.
|
|
|
|
It might exists in different variants and version that are all loaded here and can be
|
|
switched in the GUI.
|
|
|
|
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.
|
|
|
|
NOTE CONCERNING THE BELOW:
|
|
The instruments are not really described by semantic versioning. That was the initial plan
|
|
but it showed that versioning of third party instruments is difficult or impossible.
|
|
But it also is not needed nor practical. New versions are so rare that one can easily
|
|
find individual schemes to name and differentiate variants.
|
|
|
|
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 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.
|
|
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)"""
|
|
|
|
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"]))
|
|
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.
|
|
|
|
self.currentVariantKeySwitches = None #set by _parseKeys through chooseVariant . Ttuple: dict, sw_lokey, sw_highkey . See docstring of parseKeySwitches
|
|
self.currentKeySwitch:int = None # Midi pitch. Default is set on load.
|
|
|
|
self.playableKeys:tuple = None #sorted tuple of ints. set by _parseKeys through chooseVariant. Set of int pitches. Used for export.
|
|
self.controlLabels = {} #CC int:str opcode label_cc# in <control>
|
|
self.keyLabels = {} #Pitch int:str opcode label_key# in <control>
|
|
|
|
|
|
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["idKey"] = 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.
|
|
result["keySwitches"] = self.currentVariantKeySwitches[0] if self.currentVariantKeySwitches else {} #Internally this is a tuple with [0] being a dict: Unordered!! dict with midiPitch: (opcode, label). You need the opcode to see if it is a momentary switch or permanent.
|
|
result["currentKeySwitch"] = self.currentKeySwitch
|
|
result["playableKeys"] = self.playableKeys
|
|
result["keyLabels"] = self.keyLabels
|
|
result["controlLabels"] = self.controlLabels #CCs
|
|
return result
|
|
|
|
def exportMetadata(self)->dict:
|
|
"""
|
|
This is the big update that sends everything to build a GUI database
|
|
"""
|
|
parentMetadata = self.parentLibrary.config["library"]
|
|
|
|
result = {}
|
|
result["id"] = int(self.metadata["id"]) #int
|
|
result["idKey"] = 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
|
|
result["status"] = self.exportStatus() #a dict #TODO: This is a problem because metadata is only exported on program start. This could lead functions to try to check for state/enabled in here, which would be always false. This is only valid on program start/load from file
|
|
|
|
#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.
|
|
Used for loading save files as well as manual loading
|
|
"""
|
|
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 variantSfzFileName: #Can happen if there is an accidental empty entry in the library.ini
|
|
logger.error(f"{self.name} with id key {self.idKey} tried to load empty sfz variant. Probably a library.ini typo.")
|
|
return
|
|
|
|
if not self.enabled:
|
|
raise RuntimeError(f"{self.name} with id key {self.idKey} tried to load variant {variantSfzFileName} but was not yet enabled")
|
|
|
|
if not variantSfzFileName in self.variants:
|
|
raise ValueError("Variant not in list: {} {}".format(variantSfzFileName, self.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.variants.index(variantSfzFileName)
|
|
#We do NOT use the program number, that was a mistake in the past. Set programNumber to 0 so it overwrites the current file.
|
|
|
|
self.program = self.instrumentLayer.engine.load_patch_from_tar(0, self.tarFilePath, self.rootPrefixPath+variantSfzFileName, self.metadata["name"]) #program number fixed to 0, tar_name, sfz_name, display_name
|
|
#self.program = self.instrumentLayer.engine.get_patches()[0][1] workaround in the past, when load_patch_from_tar returned None by mistake.
|
|
#Turns out we do not even need set_patch.
|
|
#self.instrumentLayer.engine.set_patch(1, 0) #1 is supposed to be the channel, 0 is the program. But it listens to all 16 channels anyway.
|
|
#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)}
|
|
# This is similar to program.status() which returns
|
|
# SamplerProgram:in_use=16 name=CelloEns-KS.sfz program_no=0 sample_dir=./Strings/Cello Section/pizzT/ source_file=./CelloEns-KS.sfz
|
|
# This is on program number 0 and listens on all 16 channels.
|
|
#Only ever index 0 is used because we have one patch per port
|
|
|
|
status = self.program.status()
|
|
logger.info(status)
|
|
assert variantSfzFileName in status.name, (status.name, variantSfzFileName) #this is NOT the same. Salamander Drumkit status.name == 'drumkit/ALL.sfz', variantSfzFileName == 'ALL.sfz'
|
|
assert status.in_use == 16, status.SamplerProgram
|
|
assert status.program_no == 0, status.program_no
|
|
self.currentVariant = variantSfzFileName
|
|
|
|
self.currentVariantKeySwitches = self._parseKeyInfoAndLabels()
|
|
if self.currentVariantKeySwitches and self.currentVariantKeySwitches[3]: #[3] is sw_default
|
|
self.currentKeySwitch = self.currentVariantKeySwitches[3]
|
|
else:
|
|
self.currentKeySwitch = None
|
|
|
|
logger.info(f"Finished loading samples for instrument {variantSfzFileName} with id key {self.idKey}")
|
|
|
|
def _parseKeyInfoAndLabels(self):
|
|
"""
|
|
Called only by chooseVariant. This is a function only for readability reasons and for the
|
|
docstring.
|
|
|
|
Returns a tuple: dict, sw_lokey, sw_highkey
|
|
|
|
dict with key=keystring e.g. c#4
|
|
and value=(opcode,label). label can be empty.
|
|
|
|
keys can only get parsed if the program is actually loaded/enabled.
|
|
|
|
Only existing keyswitches are included, not every number from 0-127.
|
|
Two special keys "sw_lokey" and "sw_hikeys" are returned and show the total range of possible
|
|
keyswitches.
|
|
|
|
This is currently a function to find the most common keyswitches, not every advanced scenario
|
|
with sw_previous for context-sensitive-regions (which isn't really a keyswitch),
|
|
nor sw_lokey, sw_hikey and multiple parallel switches per key.
|
|
|
|
Specifically it just searches for sw_last, sw_down and sw_up and assumes that any level (master,
|
|
group, region) can only use one of the three.
|
|
|
|
sw_down and sw_up are implementation-dependent. We must assume that there are instruments that
|
|
use these opcodes without specifying sw_lokey and sw_hikey. sw_last requires the range.
|
|
For these reasons we cannot do sanity-checking here. We just report all single-key keyswitches.
|
|
|
|
Finally it assumes that there is one sw_lokey and one sw_hikey in the whole file, and it
|
|
must be in global. No actual keyswitches will be in global.
|
|
|
|
For example:
|
|
|
|
VSCO Strings - Cello parses as follows:
|
|
( #Dict with all keyswitches and labels. Label can be empty string.
|
|
{'d6': ('sw_last', 'D6 Spiccato'),
|
|
'c6': ('sw_last', 'C6 Sustain Vibrato'),
|
|
'd#6': ('sw_last', 'D#6 Pizzicato'),
|
|
'c#6': ('sw_last', 'C#6 Tremolo')},
|
|
'', #sw_lokey not set
|
|
'') #sw_highkey not set
|
|
|
|
Calfbox with default settings will report keys as string,
|
|
no matter if they are entered as pitch-numbers or keystrings in the .sfz file itself.
|
|
We convert them here ourselves.
|
|
|
|
We assume there can only be one sw_default. We allow redundant entries but will just
|
|
use the last value we encounter. Logical consistency must be checked with an external tool
|
|
or process.
|
|
"""
|
|
|
|
if not self.enabled:
|
|
logger.warning(f"Something tried to set the keyswitch but this instrument {self.name} {self.currentVariant} is currently not enabled. Nothing was changed.")
|
|
return
|
|
if not self.currentVariant:
|
|
logger.warning(f"Something tried to parse keyswitches but this instrument {self.name} {self.currentVariant} currently has no variant loaded. Nothing was changed.")
|
|
return
|
|
|
|
def findKS(data, writeInResult, writeInOthers):
|
|
|
|
if "sw_label" in data:
|
|
label = data["sw_label"]
|
|
#remove leading int or key from label
|
|
mMidipitch = re.match("\d+", label)
|
|
mNotename = re.match("(c|(c#)|(db)|d|(d#)|(eb)|e|(e#)|(fb)|f|(f#)|(gb)|g|(g#)|(ab)|a|(a#)|(bb)|b|(b#))\d+", label, re.IGNORECASE)
|
|
if mMidipitch and not label[1] == "'" and not label[2] == "'": # 8' or 16' organs begin with a number as well. We sadly can't check for a space after the number so we have to check for the foot symbol: #could be None
|
|
label = label[mMidipitch.span()[1]:].lstrip() #remove number and potential leading space
|
|
elif mNotename:
|
|
label = label[mNotename.span()[1]:].lstrip() #remove notenames like C#6 and potential leading space
|
|
else:
|
|
label = ""
|
|
|
|
if "sw_default" in data:
|
|
if "sw_default" in writeInOthers and writeInOthers["sw_default"] and writeInOthers["sw_default"] != data["sw_default"]:
|
|
logger.error(f"Instrument {self.name} {self.currentVariant} has multiple different sw_default values. We will use the last one encountered. This conflict: {writeInOthers['sw_default']} vs {data['sw_default']} ")
|
|
writeInOthers["sw_default"] = data["sw_default"]
|
|
|
|
if "sw_last" in data:
|
|
midiPitch = midiName2midiPitch[data["sw_last"]]
|
|
writeInResult[midiPitch] = "sw_last", label
|
|
elif "sw_down" in data:
|
|
midiPitch = midiName2midiPitch[data["sw_down"]]
|
|
writeInResult[midiPitch] = "sw_down", label
|
|
elif "sw_up" in data:
|
|
midiPitch = midiName2midiPitch[data["sw_up"]]
|
|
writeInResult[midiPitch] = "sw_up", label
|
|
|
|
def findPlayableKeys(data:dict, writeInResult:set):
|
|
"""Playable keys can be on any level. Mostly groups and regions though."""
|
|
|
|
if "key" in data:
|
|
notePitch:int = midiName2midiPitch[data["key"]]
|
|
writeInResult.add(notePitch)
|
|
if "lokey" in data and "hikey" in data:
|
|
#Guard
|
|
if data["lokey"] == "-1" or data["hikey"] == "-1": #"lokey and hikey to -1, to prevent a region from being triggered by any keys." https://sfzformat.com/opcodes/lokey
|
|
return
|
|
|
|
lower = midiName2midiPitch[data["lokey"]]
|
|
higher = midiName2midiPitch[data["hikey"]]
|
|
|
|
if lower > higher:
|
|
logger.error(f"Instrument {self.name} {self.currentVariant} SFZ problem: lokey {lower} is higher than hikey {higher}")
|
|
return
|
|
for notePitch in range(lower, higher+1):
|
|
writeInResult.add(notePitch)
|
|
|
|
logger.info(f"Start parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}")
|
|
|
|
result = {} # int:tuple(opcode, keyswitch-label)
|
|
others = {} # var:var
|
|
hierarchy = self.program.get_hierarchy() #starts with global and dicts down with get_children(). First single entry layer is get_global()
|
|
|
|
allKeys = set()
|
|
for k,v in hierarchy.items(): #Global
|
|
globalData = k.as_dict()
|
|
swlokeyValue = globalData["sw_lokey"] if "sw_lokey" in globalData else ""
|
|
swhikeyValue = globalData["sw_hikey"] if "sw_hikey" in globalData else ""
|
|
others["sw_default"] = globalData["sw_default"] if "sw_default" in globalData else ""
|
|
for k1,v1 in v.items(): #Master
|
|
k1AsDict = k1.as_dict()
|
|
findPlayableKeys(k1AsDict, allKeys)
|
|
findKS(k1AsDict, result, others)
|
|
if v1:
|
|
for k2,v2 in v1.items(): #Group
|
|
k2AsDict = k2.as_dict()
|
|
findPlayableKeys(k2AsDict, allKeys)
|
|
findKS(k2AsDict, result, others)
|
|
if v2:
|
|
for k3,v3 in v2.items(): #Regions
|
|
k3AsDict = k3.as_dict()
|
|
findPlayableKeys(k3AsDict, allKeys)
|
|
findKS(k3AsDict, result, others)
|
|
|
|
self.playableKeys = tuple(sorted(allKeys))
|
|
self.controlLabels = self.program.get_control_labels() #opcode label_cc# in <control>
|
|
|
|
self.keyLabels = self.program.get_key_labels() #opcode label_cc# in <control>
|
|
#Add some defaults.
|
|
for k,v in {60:"Middle C", 53:"𝄢", 67:"𝄞"}.items():
|
|
if not k in self.keyLabels:
|
|
self.keyLabels[k] = v
|
|
|
|
logger.info(f"Finished parsing possible keyswitches in the current variant/cbox-program for {self.name} {self.currentVariant}. Found: {len(result)} keyswitches.")
|
|
if not result:
|
|
return None
|
|
else:
|
|
return (result, swlokeyValue, swhikeyValue, midiName2midiPitch[others["sw_default"]])
|
|
|
|
|
|
def setKeySwitch(self, keySwitchMidiPitch:int):
|
|
"""Set the current variant to it's keySwitch number keySwitchIndex.
|
|
We use the key-midi-pitch directly.
|
|
We also check against our own parsing on startup if the variant has this keyswitch and
|
|
do a logger warning if the keyswitch does not exist, and then do nothing.
|
|
|
|
Keyswitches get internally reset on variant-switching and use the one specified as default
|
|
in the variant-.sfz.
|
|
"""
|
|
if not self.enabled:
|
|
logger.warning(f"Tried to set the keyswitch but this instrument {self.name} is currently not enabled. Nothing was changed.")
|
|
return
|
|
if not self.currentVariant:
|
|
logger.warning(f"Tried to parse keyswitches but this instrument {self.name} currently has no variant loaded. Nothing was changed.")
|
|
return
|
|
|
|
cboxReportedKeySwitchesRange = self.program.get_keyswitch_groups()[0] # This is ALWAYS two values. It is a range!
|
|
keySwitchDict = self.currentVariantKeySwitches[0]
|
|
|
|
if not keySwitchMidiPitch in keySwitchDict:
|
|
logger.warning(f"Tried setting instrument {self.name} to key switch {keySwitchMidiPitch} but that switch was not parsed on load. Nothing was changed. We parsed: {keySwitchDict} in the range from/to {cboxReportedKeySwitchesRange}")
|
|
return
|
|
|
|
self.scene.send_midi_event(0x90, keySwitchMidiPitch, 64) #note on with vel 64
|
|
currentKeySwitch = self.instrumentLayer.engine.get_keyswitch_state(1, 0) #midi channel indexFrom1, keyswitch group
|
|
#Confirm that we changed the switch for both development and release version
|
|
assert currentKeySwitch == keySwitchMidiPitch, (currentKeySwitch, keySwitchMidiPitch)
|
|
if not currentKeySwitch == keySwitchMidiPitch:
|
|
logger.error(f"Tried setting instrument {self.name} to key switch {keySwitchMidiPitch} but afterwards we are switch {currentKeySwitch}. Cause unknown. Not sure if this is a problem or not.")
|
|
|
|
return self.updateCurrentKeySwitch()
|
|
|
|
def updateCurrentKeySwitch(self, force=None):
|
|
"""This is either called directly by setKeySwitch after a user change
|
|
but also from our midi event checker in python.
|
|
Returns a tuple (stateChangedSinceLastCheck:bool, keySwitchMidiPitch)
|
|
|
|
This is called on every(!) note on.
|
|
|
|
This is only for permanent sw_last, not momentary sw_up and sw_down
|
|
"""
|
|
if not self.currentVariantKeySwitches or not self.instrumentLayer:
|
|
return #optimisation.
|
|
|
|
if not force is None:
|
|
changed = self.currentKeySwitch != force
|
|
new = force
|
|
else:
|
|
new = self.instrumentLayer.engine.get_keyswitch_state(1, 0) #midi channel indexFrom1, keyswitch group(!! ugly !!. We don't support that at all.)
|
|
changed = self.currentKeySwitch != new
|
|
|
|
self.currentKeySwitch = new
|
|
|
|
return changed, new
|
|
|
|
|
|
|
|
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.cboxPortname = cbox.JackIO.status().client_name + ":" + self.midiInputPortName
|
|
|
|
|
|
self.midiProcessor = MidiProcessor(parentInput = self) #works through self.cboxMidiPortUid
|
|
self.midiProcessor.register_NoteOn(self.triggerNoteOnCallback)
|
|
self.midiProcessor.register_NoteOff(self.triggerNoteOffCallback)
|
|
self.midiProcessor.register_CC(self.triggerCCCallback)
|
|
#self.midiProcessor.notePrinter(True)
|
|
self.parentLibrary.parentData.parentSession.eventLoop.fastConnect(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 triggerNoteOnCallback(self, timestamp, channel, pitch, velocity):
|
|
"""args are: timestamp, channel, note, velocity.
|
|
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
|
|
self.parentLibrary.parentData.instrumentMidiNoteOnActivity(self.idKey, pitch, velocity)
|
|
|
|
def triggerNoteOffCallback(self, timestamp, channel, pitch, velocity):
|
|
"""args are: timestamp, channel, note, velocity.
|
|
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
|
|
self.parentLibrary.parentData.instrumentMidiNoteOffActivity(self.idKey, pitch, velocity)
|
|
|
|
def triggerCCCallback(self, timestamp, channel, ccNumber, value):
|
|
"""args are: timestamp, channel, ccNumber, value
|
|
consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()"""
|
|
self.parentLibrary.parentData.instrumentCCActivity(self.idKey, ccNumber, value)
|
|
|
|
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"Instrument was instructed to connect to port {externalPort}, which does not exist")
|
|
cbox.JackIO.port_connect(externalPort, self.cboxPortname)
|
|
|
|
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 for the instrument loaded state.
|
|
"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
|
|
|