#! /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 . """ import logging; logger = logging.getLogger(__name__); logger.info("import") #Python Standard Lib import os.path from os import PathLike from typing import List, Dict, Union #Third Party from template.calfbox import cbox #Template Modules from .data import Data from .input_midi import MidiInput #Client Modules from engine.config import * #imports METADATA class Sampler_sf2(Data): """ Channels are base 1 Programs and banks are base 0 We cache the complete list of banks/programs in self.patchlist but we always fetch the active patches (16 channels) live from calfbox through self.activePatches() """ def __init__(self, parentSession, filePath, activePatches, ignoreProgramChanges, defaultSoundfont=None): super().__init__(parentSession) self._unlinkOnSave:List[Union[str, PathLike]] = [] #old sf2 files no longer in use. self.filePath = filePath self.patchlist:Dict[int,Dict[int, str]] = {} #bank:{program:name} self.buffer_ignoreProgramChanges = ignoreProgramChanges self.midiInput = MidiInput(session=parentSession, portName="all") self.midiInputJackName = f"{cbox.JackIO.status().client_name}:all" cbox.JackIO.Metadata.set_port_order(self.midiInputJackName, 0) #first one. The other port order later start at 1 because we loop 1-based for channels. #Set libfluidsynth! to 16 output pairs. We prepared 32 jack ports in the session start. "soundfont" is our given name, in the line below. This is a prepared config which will be looked up by add_new_instrument cbox.Config.set("instrument:soundfont", "engine", "fluidsynth") cbox.Config.set("instrument:soundfont", "output_pairs", 16) #this is not the same as session.py cbox.Config.set("io", "outputs", METADATA["cboxOutputs"]). It is yet another layer and those two need connections #Create the permanent instrument which loads changing sf2 layer = self.midiInput.scene.add_instrument_layer("soundfont") self.instrumentLayer = layer self.instrument = layer.get_instrument() self.loadSoundfont(filePath, defaultSoundfont) #Block incoming program and bank changes per midi. #Save and load is done via the instance variable. self.midiInput.scene.status().layers[0].set_ignore_program_changes(ignoreProgramChanges) assert self._ignoreProgramChanges == ignoreProgramChanges #Active patches is a dynamic value. Load the saved ones here, but do not save their state locally try: self.setActivePatches(activePatches) except: self._correctActivePatches() #Create a dynamic pair of audio output ports and route all stereo pairs to our summing channel. #This does _not_ need updating when loading another sf2 or changing instruments. #There is also a function below to connect those to system 1/2 lmixUuid = cbox.JackIO.create_audio_output('left_mix') rmixUuid = cbox.JackIO.create_audio_output('right_mix') for i in range(16): router = cbox.JackIO.create_audio_output_router(lmixUuid, rmixUuid) router.set_gain(-3.0) self.instrument.get_output_slot(i).rec_wet.attach(router) #output_slot is 0 based and means a pair #slot 17 is an error. cbox tells us there is only [1, 16], good. #Set port order. It is already correct because alphanumerial order, but explicit is better than implicit for channelNumber in range(1,33): portname = f"{cbox.JackIO.status().client_name}:out_{channelNumber}" try: cbox.JackIO.Metadata.set_port_order(portname, channelNumber) except Exception as e: #No Jack Meta Data logger.error(e) break #Also sort the mixing channels try: portname = f"{cbox.JackIO.status().client_name}:left_mix" cbox.JackIO.Metadata.set_port_order(portname, 33) portname = f"{cbox.JackIO.status().client_name}:right_mix" cbox.JackIO.Metadata.set_port_order(portname, 34) except Exception as e: #No Jack Meta Data logger.error(e) #If demanded create 16 routing midi channels, basically a jack port -> midi channel merger. #TODO: This is not working. Better leave that to JACK itself and an outside program for now. #Also deactivated the pretty name metadata update call in updateChannelMidiInJackMetadaPrettyname. It is called by an api callback and by our "all" function if False: for midiRouterPortNum in range(1,17): portName = f"channel_{str(midiRouterPortNum).zfill(2)}" router_cboxMidiPortUid = cbox.JackIO.create_midi_input(portName) router_scene = cbox.Document.get_engine().new_scene() router_scene.clear() router_scene.set_enable_default_song_input(False) #router_scene.add_instrument_layer("soundfont") #This creates multiple sf2 instances router_midi_layer = router_scene.add_new_midi_layer(router_cboxMidiPortUid) #Create a midi layer for our input port. That layer supports manipulation like transpose or channel routing. #cbox.JackIO.route_midi_input(router_cboxMidiPortUid, router_scene.uuid) router_midi_layer.set_out_channel(midiRouterPortNum) router_midi_layer.set_in_channel(midiRouterPortNum) #router_midi_layer.set_enable(False) #cbox.JackIO.route_midi_input(router_cboxMidiPortUid, self.midiInput.scene.uuid) #routes directly to the instrument and ignores the layer options like channel forcing #cbox.JackIO.route_midi_input(router_cboxMidiPortUid, self.midiInput.cboxMidiPortUid) #runs, but does nothing #cbox.JackIO.route_midi_input(router_midi_layer, self.midiInput.cboxMidiPortUid) #does not run router_midi_layer.set_external_output(self.midiInput.cboxMidiPortUid) #router_midi_layer.set_external_output(self.midiInput.scene.uuid) #Port order fullPortname = f"{cbox.JackIO.status().client_name}:out_{channelNumber}" try: cbox.JackIO.Metadata.set_port_order(fullPortname, midiRouterPortNum) except Exception as e: #No Jack Meta Data pass #don't break here. @property def _ignoreProgramChanges(self): try: result = self.midiInput.scene.status().layers[0].status().ignore_program_changes self.buffer_ignoreProgramChanges = result except IndexError: #No layers means no loaded instrument yet. e.g. program start or in between loading. result = self.buffer_ignoreProgramChanges return result def unlinkUnusedSoundfonts(self, stopSession=False): if stopSession and self.parentSession.lastSavedData and not self.filePath == self.parentSession.lastSavedData["filePath"]: #The user might want to discard the current unsaved setup (self.filePath) by closing without saving. #self.filePath holds the current file, not the saved file. We override to not get the actual saved setting deleted on quit. self._unlinkOnSave.append(self.filePath) self.filePath = self.parentSession.lastSavedData["filePath"] for filePath in self._unlinkOnSave: if not filePath == self.filePath and os.path.exists(filePath): os.unlink(filePath) self._unlinkOnSave = [] def _convertPatches(self, dictionary:dict): """Returns a dict of dicts: bank:{program:name}""" result:Dict[int,Dict[int, str]] = {} #same as self.patchlist for value, name in dictionary.items(): program = value & 127 bank = value >> 7 if not bank in result: result[bank] = {} result[bank][program] = name return result def loadSoundfont(self, filePath, defaultSoundfont=None): """defaultSoundfont is a special case. The path is not saved""" logger.info(f"loading path: \"{filePath}\" defaultSoundfont parameter: {defaultSoundfont}") #Remove the old link, if present. We cannot unlink directly in loadSoundfont because it is quite possible that a user will try out another soundfont but decide not to save but close and reopen to get his old soundfont back. if self.filePath and os.path.islink(self.filePath): self._unlinkOnSave.append(self.filePath) #self.midiInput.scene.clear() do not call for the sf2 engine. Will kill our instrument. for stereoPair in range(16): #midi channels fluidsynth output . 0-15 #print (stereoPair) slot = self.instrument.get_output_slot(stereoPair) #slot is 0 based slot.set_output(stereoPair+1) #output is 1 based. setOutput routes to cbox jack ports, which are the same order. self.patchlist = {} try: if defaultSoundfont: self.instrument.engine.load_soundfont(defaultSoundfont) self.filePath = "" #redundant, but not wrong. else: self.instrument.engine.load_soundfont(filePath) self.filePath = filePath self.patchlist = self._convertPatches( self.instrument.engine.get_patches() ) self._correctActivePatches() return True, "" except Exception as e: #throws a general Exception if not a good file #TODO: An error here leads to an undefined program state with no soundfont loaded. Either restore the old state perfectly (but why?) or go into a defined "nothing loaded" state. if os.path.exists(filePath) and os.path.islink(filePath): os.unlink(filePath) #the file was maybe already linked. undo return False, str(e) def _correctActivePatches(self): """Check if the current choice of bank and program actually exists in the soundfont and fall back to an existing value (or error). We keep no internal state of active patches but always ask cbox. Therefore we directly change cbox here and now.""" if not self.patchlist: self.patchlist = self._convertPatches( self.instrument.engine.get_patches() ) #try again if not self.patchlist: raise ValueError("No programs in this sound font") for channel, (value, name) in self.instrument.engine.status().patch.items(): #channel is base 1 program = value & 127 bank = value >> 7 if bank in self.patchlist and program in self.patchlist[bank]: continue else: #just pick one at random for youchoose_bank in self.patchlist.keys(): for youchoose_program, youchoose_name in self.patchlist[youchoose_bank].items(): youchoose_bank, youchoose_program, youchoose_name logger.info(METADATA["name"] + f": Channel {channel} Old bank/program not possible in new soundfont. Switching to first available program.") self.setPatch(channel, youchoose_bank, youchoose_program) break #inner loop. one instrument is enough. def activePatches(self): """Returns a dict of 16 tuples: channel(count from 1):(int bank, int program, str name) "Acoustic Piano" is the default name, since that is fluidsynth behaviour. Takes the current bank into consideration """ result = {} for channel, (value, name) in self.instrument.engine.status().patch.items(): program = value & 127 bank = value >> 7 result[channel] = (bank,program,name) #the name doesn't matter for the program at all, it is only to get a human readable save file. return result def setActivePatches(self, activePatchesDict): """used for loading. receives an activePatches dict and sets all banks and programs accordingly""" assert self.patchlist, self.patchlist for channel, (bank, program, name) in activePatchesDict.items(): #the name doesn't matter for the program at all, it is only to get a human readable save file. self.setPatch(channel, bank, program) def setPatch(self, channel, bank, program): """An input error happens not often, but can happen if the saved data mismatches the soundfont. This happens on manual save file change or soundfont change (version update?) between program runs. We assume self.patchlist is up to date.""" #After changing the bank we need to check if the program still exists. #If not fall back to an existing program if not program in self.patchlist[bank]: for i in range(128): #0-127 if i in self.patchlist[bank]: program = i break else: return ValueError(f"No Program in Bank {bank}") newValue = program | (bank<<7) self.instrument.engine.set_patch(channel, newValue) #call to cbox def updateChannelAudioJackMetadaPrettyname(self, channel:int): """Audio output channel pairs 1-16""" bank, program, name = self.activePatches()[channel] #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index. chanL = channel*2-1 #channel is 1-based. [1]*2-1 = 1. chanR = channel*2 portnameL = f"{cbox.JackIO.status().client_name}:out_{chanL}" portnameR = f"{cbox.JackIO.status().client_name}:out_{chanR}" #Use the instrument name as port name: 03-L:Violin try: cbox.JackIO.Metadata.set_pretty_name(portnameL, f"{str(channel).zfill(2)}-L : {name}") cbox.JackIO.Metadata.set_pretty_name(portnameR, f"{str(channel).zfill(2)}-R : {name}") except Exception as e: #No Jack Meta Data logger.error(e) def updateChannelMidiInJackMetadaPrettyname(self, channel:int): """Midi in single channels 1-16""" return #TODO: Until this works. bank, program, name = self.activePatches()[channel] #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index. portName = f"channel_{str(channel).zfill(2)}" fullPortname = f"{cbox.JackIO.status().client_name}:{portName}" #Use the instrument name as port name: 03-L:Violin try: cbox.JackIO.Metadata.set_pretty_name(fullPortname, f"{str(channel).zfill(2)} : {name}") except Exception as e: #No Jack Meta Data logger.error(e) def updateAllChannelJackMetadaPrettyname(self): """Add this to a callback. It can't be run on program startup because sampler_sf2 is initiated before cbox creates its audio ports. It is advised to use this in a controlled manner. There is no internal check if instruments changed and subsequent renaming. Multiple changes in a row are common, therefore the place to update is in the API, where the new state is also sent to the UI. """ for channel in range(1,17): self.updateChannelAudioJackMetadaPrettyname(channel) self.updateChannelMidiInJackMetadaPrettyname(channel) """ for channel, (bank, program, name) in self.activePatches().items(): chanL = channel*2-1 #channel is 1-based. [1]*2-1 = 1. chanR = channel*2 portnameL = f"{cbox.JackIO.status().client_name}:out_{chanL}" portnameR = f"{cbox.JackIO.status().client_name}:out_{chanR}" #Use the instrument name as port name: 03-L:Violin cbox.JackIO.Metadata.set_pretty_name(portnameL, f"{str(channel).zfill(2)}-L : {name}") cbox.JackIO.Metadata.set_pretty_name(portnameR, f"{str(channel).zfill(2)}-R : {name}") """ def getMidiInputNameAndUuid(self): """ Return name and cboxMidiPortUid. name is Client:Port JACK format Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and sequencers. Used by the quick connect midi input widget. If double None as return the widget in the GUI might hide and deactivate itself.""" return self.midiInputJackName, self.midiInput.cboxMidiPortUid def connectMixerToSystemPorts(self): hardwareAudioPorts = cbox.JackIO.get_ports("system*", cbox.JackIO.AUDIO_TYPE, cbox.JackIO.PORT_IS_SINK | cbox.JackIO.PORT_IS_PHYSICAL) #don't sort. This is correctly sorted. Another sorted will do 1, 10, 11, l = f"{cbox.JackIO.status().client_name}:left_mix" r = f"{cbox.JackIO.status().client_name}:right_mix" cbox.JackIO.port_connect(l, hardwareAudioPorts[0]) cbox.JackIO.port_connect(r, hardwareAudioPorts[1]) #Save / Load / Export def serialize(self)->dict: self.unlinkUnusedSoundfonts() #We do not want them to be in the save file. return { "filePath" : self.filePath, "activePatches" : self.activePatches(), "ignoreProgramChanges" : self.midiInput.scene.status().layers[0].status().ignore_program_changes, #includes bank changes } @classmethod def instanceFromSerializedData(cls, parentSession, serializedData): """The entry function to create a sampler state from saved data. It is called by the session. This functions triggers a tree of other createInstanceFromSerializedData which finally returns Sampler_sf2, which gets saved in the session. The serializedData is already converted to primitive python types from json, but nothing more. Here we create the actual objects.""" self = cls(parentSession=parentSession, #Other parameters or set loaded data different from standard init after creation filePath=serializedData["filePath"], activePatches=serializedData["activePatches"], ignoreProgramChanges=serializedData["ignoreProgramChanges"], ) return self def exportChannel(self, channel:int): bank, program, name = self.activePatches()[channel] #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index. return { "channel" : channel, "bank": bank, "program": program, "name": name, } def export(self)->dict: prettyName = os.path.basename(self.filePath) if prettyName.endswith(".sf2"): prettyName = prettyName[:-4] return { "filePath":self.filePath, "patchlist":self.patchlist, "activePatches":self.activePatches(), "name": prettyName.title(), }