#! /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 ( ) ]
}