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.

244 lines
11 KiB

3 years ago
#! /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
3 years ago
#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
3 years ago
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.
3 years ago
"""
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
#Parse all tar libraries and load them all. ALL OF THEM!
#for each library in ...
self.libraries = {} # libraryId:int : Library-object
basePath = pathlib.Path("/home/nils/samples/Tembro/out/") #TODO: replace with system-finder /home/xy or /usr/local/share or /usr/share etc.
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
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
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
#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 hirarchy 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 = False #TODO: If we have images etc. in the future.
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: #the default case for now.
iniName = str(tarFilePath)[:-4] + ".ini"
self.config = configparser.ConfigParser()
self.config.read(iniName)
self.id = self.config["library"]["id"]
instrumentSections = self.config.sections()
instrumentSections.remove("library")
instrumentsInLibraryCount = len(instrumentSections)
self.instruments = {} # instrId : Instrument()
for iniSection in instrumentSections:
instrObj = Instrument(self, self.config["library"]["id"], self.config[iniSection], tarFilePath)
instrObj.instrumentsInLibraryCount = instrumentsInLibraryCount
self.instruments[self.config[iniSection]["id"]] = 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 self.config["library"]["id"] == self.id, (self.config["library"]["id"], self.id)
libDict["tarFilePath"] = self.tarFilePath
libDict["id"] = 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()]
}