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.
303 lines
14 KiB
303 lines
14 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 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()]
|
|
}
|
|
|