Sampled Instrument Player with static and monolithic design. All instruments are built-in.
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.
 
 

305 lines
14 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
import configparser
import pathlib
import tarfile
from io import TextIOWrapper
#Third Party
from calfbox import cbox
#Template Modules
from template.engine.data import Data as TemplateData
from template.start import PATHS
#Our Modules
from engine.instrument import Instrument
from engine.auditioner import Auditioner
class Data(TemplateData):
"""There must always be a Data class in a file main.py.
The main data is in:
self.instruments= {} # (libraryId, instrumentId):Instrument()-object
This is created on program startup and never modified afterwards (except internal instrument
changes of course).
Throughout the program we identify instruments with these unique values:
* libraryId : integer, no zero-padding. One for each tar file.
* instrumentId : integer, no zero-padding. Unique only within a tar file.
* variant: string. An .sfz file name. Can use all characters allowed as linux file name,
including spaces. Case sensitive.
"""
def __init__(self, parentSession): #Program start.
super().__init__(parentSession)
session = self.parentSession #self.parentSession is already defined in template.data. We just want to work conveniently in init with it by creating a local var.
self._processAfterInit()
def _processAfterInit(self):
session = self.parentSession #We just want to work conveniently in init with it by creating a local var.
self.cachedSerializedDataForStartEngine = None
def parseAndLoadInstrumentLibraries(self, baseSamplePath):
"""Called once by api.startEngine, which receives the global sample path from
the GUI"""
#Parse all tar libraries and load them all. ALL OF THEM!
#for each library in ...
self.libraries = {} # libraryId:int : Library-object
s = pathlib.Path(PATHS["share"])
defaultLibraryPath = s.joinpath("000 - Default.tar")
logger.info(f"Loading default test library from {defaultLibraryPath}")
defaultLib = Library(parentData=self, tarFilePath=defaultLibraryPath)
self.libraries[defaultLib.id] = defaultLib
#basePath = pathlib.Path("/home/nils/samples/Tembro/out/") #TODO: replace with system-finder /home/xy or /usr/local/share or /usr/share etc.
basePath = pathlib.Path(baseSamplePath) #self.baseSamplePath is injected by api.startEngine.
logger.info(f"Start loading samples from {baseSamplePath}")
for f in basePath.glob('*.tar'):
if f.is_file() and f.suffix == ".tar":
lib = Library(parentData=self, tarFilePath=f)
self.libraries[lib.id] = lib
logger.info(f"Finished loading samples from {baseSamplePath}")
if not self.libraries:
logger.error("There were no sample libraries to parse! This is correct on the first run, since you still need to choose a sample directory.")
self.instrumentMidiNoteOnActivity = None # the api will inject a callback function here which takes (libId, instrId) as parameter to indicate midi noteOn activity for non-critical information like a GUI LED blinking. The instruments individiual midiprocessor will call this as a parent-call.
self._createGlobalPorts() #in its own function for readability
self._createCachedJackMetadataSorting()
def _createGlobalPorts(self):
"""Create two mixer ports, for stereo. Each instrument will not only create their own jack
out ports but also connect to these left/right.
If we are not in an NSM Session auto-connect them to the system ports for convenience.
Also create an additional stereo port pair to pre-listen to on sample instrument alone,
the Auditioner.
"""
assert not self.parentSession.standaloneMode is None
if self.parentSession.standaloneMode:
self.lmixUuid = cbox.JackIO.create_audio_output('left_mix', "#1") #add "#1" as second parameter for auto-connection to system out 1
self.rmixUuid = cbox.JackIO.create_audio_output('right_mix', "#2") #add "#2" as second parameter for auto-connection to system out 2
else:
self.lmixUuid = cbox.JackIO.create_audio_output('left_mix')
self.rmixUuid = cbox.JackIO.create_audio_output('right_mix')
self.auditioner = Auditioner(self)
def exportMetadata(self)->dict:
"""Data we sent in callbacks. This is the initial 'build-the-instrument-database' function.
Each first level dict contains another dict with instruments, but also a special key
"library" that holds the metadata for the lib itself.
"""
result = {}
for libId, libObj in self.libraries.items():
result[libId] = libObj.exportMetadata() #also a dict. Contains a special key "library" which holds the library metadata itself
return result
def _createCachedJackMetadataSorting(self):
"""Calculate once, per programstart, what port order we had if all instruments were loaded.
In reality they are not, but we can still use this cache instead of dynamically creating
a port order.
Needs to be called after parsing the tar/ini files."""
#order = {portName:index for index, portName in enumerate(track.sequencerInterface.cboxPortName() for track in self.tracks if track.sequencerInterface.cboxMidiOutUuid)}
highestLibId = max(self.libraries.keys())
highestInstrId = max(instId for libId, instId in Instrument.allInstruments.keys())
clientName = cbox.JackIO.status().client_name
order = {}
orderCounter = 0
for (libraryId, instrumentId), instr in Instrument.allInstruments.items():
L = clientName + ":" + instr.midiInputPortName + "_L"
R = clientName + ":" + instr.midiInputPortName + "_R"
order[L] = (orderCounter, instr)
orderCounter += 1
order[R] = (orderCounter, instr)
orderCounter += 1
order[clientName + ":" + instr.midiInputPortName] = (orderCounter, instr) #midi port
orderCounter +=1
self._cachedJackMedataPortOrder = order
def updateJackMetadataSorting(self):
"""
Tell cbox to reorder the tracks by metadata.
We need this everytime we enable/disable an instrument which adds/removes the
jack ports.
Luckily our data never changes. We can just prepare one order, cache it, filter it
and send that again and again.
"""
cleanedOrder = { fullportname : index for fullportname, (index, instrObj) in self._cachedJackMedataPortOrder.items() if instrObj.enabled}
try:
cbox.JackIO.Metadata.set_all_port_order(cleanedOrder) #wants a dict with {complete jack portname : sortIndex}
except Exception as e: #No Jack Meta Data or Error with ports.
logger.error(e)
#Save / Load
def serialize(self)->dict:
return {
"libraries" : [libObj.serialize() for libObj in self.libraries.values()],
}
@classmethod
def instanceFromSerializedData(cls, parentSession, serializedData):
"""As an experiment we try to load everything from this function alone and not create a
function hirarchy. Save is in a hierarchy though.
This differs from other LSS programs that most data is just static stuff.
Instead we delay loading until everything is setup and just deposit saved data here
for the startEngine call to use."""
self = cls.__new__(cls)
self.session = parentSession
self.parentSession = parentSession
self._processAfterInit()
self.cachedSerializedDataForStartEngine = serializedData
return self
def loadCachedSerializedData(self):
"""Called by api.startEngine after all static data libraries are loaded, without jack ports
or instrument samples loaded. This is the same as a the empty session without a save file.
This way the callbacks have a chance to load instrument with feedback status"""
assert self.cachedSerializedDataForStartEngine
serializedData = self.cachedSerializedDataForStartEngine
for libSerialized in serializedData["libraries"]:
libObj = self.libraries[libSerialized["id"]]
for instrSerialized in libSerialized["instruments"]:
instObj = libObj.instruments[instrSerialized["id"]]
instObj.startVariantSfzFilename = instrSerialized["currentVariant"]
if instrSerialized["currentVariant"]:
instObj.loadSamples() #will use startVariantSfzFilename to set the currentVariant
if not instrSerialized["mixerEnabled"] is None: #can be True/False. None for "never touched" or "instrument not loaded"
assert instrSerialized["currentVariant"] #mixer is auto-disabled when instrument deactivated
instObj.setMixerEnabled(instrSerialized["mixerEnabled"])
if not instrSerialized["mixerLevel"] is None: #could be 0. None for "never touched" or "instrument not loaded"
assert instrSerialized["currentVariant"] #mixerLevel is None when no instrument is loaded. mixerEnabled can be False though.
instObj.mixerLevel = instrSerialized["mixerLevel"]
class Library(object):
"""Open a .tar library and extract information without actually loading any samples.
This is for GUI data etc.
You get all metadata from this.
The samples are not loaded when Library() returns. The API can loop over Instrument.allInstruments
and call instr.loadSamples() and send a feedback to callbacks.
There is also a shortcut. First only an external .ini is loaded, which is much faster than
unpacking the tar. If no additional data like images are needed (which is always the case
at this version 1.0) we parse this external ini directly to build our database.
"""
def __init__(self, parentData, tarFilePath):#
self.parentData = parentData
self.tarFilePath = tarFilePath #pathlib.Path()
if not tarFilePath.suffix == ".tar":
raise RuntimeError(f"Wrong file {tarFilePath}")
logger.info(f"Parsing {tarFilePath}")
needTarData = True
if needTarData:
with tarfile.open(name=tarFilePath, mode='r:') as opentarfile:
iniFileObject = TextIOWrapper(opentarfile.extractfile("library.ini"))
self.config = configparser.ConfigParser()
self.config.read_file(iniFileObject)
#self.config is permant now. We can close the file object
"""
#Extract an image file. But only if it exists. tarfile.getmember is basically an exist-check that trows KeyError if not
try:
imageAsBytes = extractfile("logo.png").read() #Qt can handle the format
except KeyError: #file not found
imageAsBytes = None
"""
else: #TODO: This is permanently deactivated. Could be used in the future to load an extracted-to-cache version of the ini file. Speedup is significant, but the files get inconvenienet to download
iniName = str(tarFilePath)[:-4] + ".ini"
self.config = configparser.ConfigParser()
self.config.read(iniName)
self.id = int(self.config["library"]["id"])
instrumentSections = self.config.sections()
instrumentSections.remove("library")
instrumentsInLibraryCount = len(instrumentSections)
self.instruments = {} # instrId : Instrument()
for iniSection in instrumentSections:
instrId = int(self.config[iniSection]["id"])
instrObj = Instrument(self, self.id, self.config[iniSection], tarFilePath)
instrObj.instrumentsInLibraryCount = instrumentsInLibraryCount
self.instruments[instrId] = instrObj
#At a later point Instrument.loadSamples() must be called. This is done in the API.
def exportMetadata(self)->dict:
"""Return a dictionary with each key is an instrument id, but also a special key "library"
with our own metadata. Allows the callbacks receiver to construct a hierarchy"""
result = {}
libDict = {}
result["library"] = libDict
#Explicit is better than implicit
assert int(self.config["library"]["id"]) == self.id, (int(self.config["library"]["id"]), self.id)
libDict["tarFilePath"] = self.tarFilePath
libDict["id"] = int(self.config["library"]["id"])
libDict["name"] = self.config["library"]["name"]
libDict["description"] = self.config["library"]["description"]
libDict["license"] = self.config["library"]["license"]
libDict["vendor"] = self.config["library"]["vendor"]
for instrument in self.instruments.values():
result[instrument.id] = instrument.exportMetadata() #another dict
return result
#Save
def serialize(self)->dict:
"""The library-obj is already constructed from static default values.
We only save what differs from the default, most important the state of the instruments.
If a library gets a new instrument in between tembro-runs it will just use default values.
"""
return {
"id" : self.id, #for convenience access
"instruments" : [instr.serialize() for instr in self.instruments.values()]
}