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.
 
 

323 lines
15 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
more specifically its template base application.
The Template Base 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; logging.info("import {}".format(__file__))
#Python Standard Lib
import os.path
#Third Party
from 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, mixer:bool, defaultSoundfont=None):
super().__init__(parentSession)
self._unlinkOnSave = []
self.filePath = filePath
self.patchlist = {} #bank:{program:name}
self.buffer_ignoreProgramChanges = ignoreProgramChanges
self.midiInput = MidiInput(session=parentSession, portName="in")
#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.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.
lmixUuid = cbox.JackIO.create_audio_output('left_mix', "#1") #add "#1" as second parameter for auto-connection to system out 1
rmixUuid = cbox.JackIO.create_audio_output('right_mix', "#2") #add "#2" as second parameter for auto-connection to system out 2
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
logging.error(e)
#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
logging.error(e)
@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 = {}
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"""
logging.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
logging.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 updateChannelJackMetadaPrettyname(self, channel:int):
"""Channels 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
logging.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.updateChannelJackMetadaPrettyname(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}")
"""
#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(),
}