Browse Source

instrument loading, auditioner port, basic GUI

master
Nils 2 months ago
parent
commit
891247e297
11 changed files with 861 additions and 16151 deletions
  1. +76
    -1
      engine/api.py
  2. +143
    -0
      engine/auditioner.py
  3. +1
    -1
      engine/config.py
  4. +214
    -0
      engine/instrument.py
  5. +120
    -2
      engine/main.py
  6. +26
    -8
      qtgui/designer/mainwindow.py
  7. +52
    -14
      qtgui/designer/mainwindow.ui
  8. +182
    -39
      qtgui/instrument.py
  9. +44
    -0
      qtgui/mainwindow.py
  10. +0
    -16086
      template/calfbox/configure~
  11. +3
    -0
      template/engine/api.py

+ 76
- 1
engine/api.py View File

@@ -32,6 +32,9 @@ from calfbox import cbox
import template.engine.api #we need direct access to the module to inject data in the provided structures. but we also need the functions directly. next line:
from template.engine.api import *

#Our Modules
from engine.instrument import Instrument


#New callbacks
class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
@@ -40,6 +43,11 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks

self.tempCallback = []

self.instrumentListMetadata = []
self.startLoadingSamples = []
self.instrumentStatusChanged = []
self.auditionerInstrumentChanged = []

def _tempCallback(self):
"""Just for copy paste during development"""
export = session.data.export()
@@ -47,6 +55,39 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
func(export)
callbacks._dataChanged()


def _instrumentListMetadata(self):
"""All libraries with all instruments as meta-data dicts. Hirarchy.
A dict of dicts.
Will be sent once on program start. Does not wait until actual samples are loaded
but as soon as all data is parsed."""
export = session.data.exportMetadata()
for func in self.instrumentListMetadata:
func(export)

def _instrumentStatusChanged(self, libraryId:int, instrumentId:int):
"""For example the current variant changed"""
export = session.data.libraries[libraryId].instruments[instrumentId].exportStatus()
for func in self.instrumentStatusChanged:
func(export)
callbacks._dataChanged()

def _startLoadingSamples(self, libraryId:int, instrumentId:int):
"""The sample loading of this instrument has started.
Start flashing an LED or so.

The end of loading is signaled by a statusChange callback with the current variant included
"""
key = (libraryId, instrumentId)
for func in self.startLoadingSamples:
func(key)

def _auditionerInstrumentChanged(self, libraryId:int, instrumentId:int):
"""We just send the key. It is assumed that the receiver already has a key:metadata database"""
export = session.data.libraries[libraryId].instruments[instrumentId].exportMetadata()
for func in self.auditionerInstrumentChanged:
func(export)

#Inject our derived Callbacks into the parent module
template.engine.api.callbacks = ClientCallbacks()
from template.engine.api import callbacks
@@ -60,6 +101,40 @@ def startEngine(nsmClient):
#The order of initial callbacks must not change to avoid GUI problems.
#For example it is important that the tracks get created first and only then the number of measures
logger.info("Sending initial callbacks to GUI")
callbacks._tempCallback()

callbacks._instrumentListMetadata() #Relatively quick. Happens only once in the program
#One round of status updates for all instruments. Still no samples loaded, but we saved the status of each with our own data.
for instrument in Instrument.allInstruments.values():
callbacks._instrumentStatusChanged(*instrument.idKey)

#loadSamples() #DEBUG

logger.info("Tembro api startEngine complete")


def loadSamples():
"""Actually load all instrument samples"""
for instrument in Instrument.allInstruments.values():
callbacks._startLoadingSamples(*instrument.idKey)
instrument.loadSamples()
callbacks._instrumentStatusChanged(*instrument.idKey)
callbacks._dataChanged


def auditionerInstrument(idkey:tuple):
"""Load an indendepent instance of an instrument into the auditioner port"""
libraryId, instrumentId = idkey
originalInstrument = Instrument.allInstruments[idkey]
#It does not matter if the originalInstrument has its samples loaded or not. We just want the path
session.data.auditioner.loadInstrument(originalInstrument.tarFilePath, originalInstrument.defaultVariant)
callbacks._auditionerInstrumentChanged(libraryId, instrumentId)

def getAvailableAuditionerPorts()->dict:
"""Fetches a new port list each time it is called. No cache."""
return session.data.auditioner.getAvailablePorts()


def connectAuditionerPort(externalPort:str):
"""externalPort is in the Client:Port JACK format.
If externalPort evaluates to False it will disconnect any port."""
session.data.auditioner.connectMidiInputPort(externalPort)

+ 143
- 0
engine/auditioner.py View File

@@ -0,0 +1,143 @@
#! /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

#Third Party
from calfbox import cbox

#Template Modules

class Auditioner(object):
"""
A special instrument class.

Has access to all libraries and can quickly change its sounds.
It has one midi input and stereo output.
Music output does not contribute to global mixer summing ports.

This is another instance of the instrument library. It will not touch any CC settings
or filters of the real instrument, because there is a chance that this will change the sound
of the real instrument by accident.
"""

def __init__(self, parentData):
self.parentData = parentData
self.cboxMidiPortUid = None
self.midiInputPortName = "Auditioner"
self.cboxPortname = cbox.JackIO.status().client_name + ":" + self.midiInputPortName

#Calfbox. The JACK ports are constructed without samples at first.
self.scene = cbox.Document.get_engine().new_scene()
self.scene.clear()
layer = self.scene.add_new_instrument_layer(self.midiInputPortName, "sampler") #"sampler" is the cbox sfz engine
self.scene.status().layers[0].get_instrument().engine.load_patch_from_string(0, "", "", "") #fill with null instruments
self.instrumentLayer = self.scene.status().layers[0].get_instrument()
self.program = None #return object from self.instrumentLayer.engine.load_patch_from_tar
self.scene.status().layers[0].set_ignore_program_changes(1) #TODO: ignore different channels. We only want one channel per scene/instrument/port. #TODO: Add generic filters to filter out redundant tasks like mixing and panning, which should be done in an audio mixer.
#self.instrumentLayer.engine.set_polyphony(int)

#Create Stereo Audio Ouput Ports
#Connect to our own pair but also to a generic mixer port that is in Data()
if self.parentData.parentSession.standaloneMode:
jackAudioOutLeft = cbox.JackIO.create_audio_output(self.midiInputPortName+"_L", "#1") #add "#1" as second parameter for auto-connection to system out 1
jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R", "#2") #add "#1" as second parameter for auto-connection to system out 2
else:
jackAudioOutLeft = cbox.JackIO.create_audio_output(self.midiInputPortName+"_L")
jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R")

outputMergerRouter = cbox.JackIO.create_audio_output_router(jackAudioOutLeft, jackAudioOutRight)
outputMergerRouter.set_gain(-3.0)
instrument = layer.get_instrument()
instrument.get_output_slot(0).rec_wet.attach(outputMergerRouter) #output_slot is 0 based and means a pair. Most sfz instrument have only one stereo pair. #TODO: And what if not?

#Create Midi Input Port
self.cboxMidiPortUid = cbox.JackIO.create_midi_input(self.midiInputPortName)
cbox.JackIO.set_appsink_for_midi_input(self.cboxMidiPortUid, True) #This sounds like a program wide sink, but it is needed for every port.
cbox.JackIO.route_midi_input(self.cboxMidiPortUid, self.scene.uuid)

def exportStatus(self):
"""The call-often function to get the instrument status. Includes only data that can
actually change during runtime."""
result = {}

#Static ids
result["id"] = self.metadata["id"]
result["id-key"] = self.idKey #redundancy for convenience.

#Dynamic data
result["currentVariant"] = self.currentVariant # str
result["state"] = self.enabled #bool

return result

def loadInstrument(self, tarFilePath, variantSfzFileName:str):
"""load_patch_from_tar is blocking. This function will return when the instrument is ready
to play.

The function will do nothing when the instrument is not enabled.
"""
logger.info(f"Start loading samples for auditioner {variantSfzFileName}")
#help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc.
#newProgramNumber = self.instrumentLayer.engine.get_unused_program()
programNumber = 1
name = variantSfzFileName
self.program = self.instrumentLayer.engine.load_patch_from_tar(programNumber, tarFilePath, variantSfzFileName, name)
self.instrumentLayer.engine.set_patch(1, programNumber) #1 is the channel, counting from 1. #TODO: we want this to be on all channels.
self.currentVariant = variantSfzFileName
logger.info(f"Finished loading samples for auditioner {variantSfzFileName}")


def getAvailablePorts(self)->dict:
"""This function queries JACK each time it is called.
It returns a dict with two lists.
Keys "hardware" and "software" for the type of port.
"""
result = {}
hardware = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE | cbox.JackIO.PORT_IS_PHYSICAL))
allPorts = set(cbox.JackIO.get_ports(".*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE))
software = allPorts.difference(hardware)
result["hardware"] = sorted(list(hardware))
result["software"] = sorted(list(software))
return result


def connectMidiInputPort(self, externalPort:str):
"""externalPort is in the Client:Port JACK format
If "" False or None disconnect all ports."""

try:
currentConnectedList = cbox.JackIO.get_connected_ports(self.cboxMidiPortUid)
except: #port not found.
currentConnectedList = []

for port in currentConnectedList:
cbox.JackIO.port_disconnect(port, self.cboxPortname)

if externalPort:
availablePorts = self.getAvailablePorts()
if not (externalPort in availablePorts["hardware"] or externalPort in availablePorts["software"]):
raise RuntimeError(f"Auditioner was instructed to connect to port {externalPort}, which does not exist")

cbox.JackIO.port_connect(externalPort, self.cboxPortname)

+ 1
- 1
engine/config.py View File

@@ -40,7 +40,7 @@ METADATA={

#How many audio outputs do you want? must be pairs. These are just unconnected jack outputs
#that need to be connected internally to instrument outputs like fluidsynth
"cboxOutputs" : 2 * 4,
"cboxOutputs" : 0,

#Does the program uses a metronome? In this case you need at least two cboxOutputs above
"metronome" : False,

+ 214
- 0
engine/instrument.py View File

@@ -0,0 +1,214 @@
#! /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

#Third Party
from calfbox import cbox

#Template Modules

class Instrument(object):
"""Literally one instrument.

It might exists in different versions that are all loaded here and can be switched in the GUI.
For that we identify different .sfz files by a minor version number (see below).

All data is provided by the parsed metadata dict, except the filepath of the tar which calfbox
needs again here.
The metadata dict contains a list of all available sfz files describing variants of the same file,
eg. sharing most of the sample data. The variants filename will be used directly as "preset"
name in a GUI etc.
The variants are case sensitive filenames ending in .sfz

The order of variants in the config file will never change, it can only get appended.
This way indexing will remain consistent over time.

The default variant after the first start (no save file) is the a special entry in metadata.
It can change with new versions, so new projects will start with the newer file.

Examples:
SalamanderPiano1.2.sfz
SalamanderPiano1.3.sfz
SalamanderPiano1.6.sfz

Here we have versions 1.2, 1.3 and 1.6. 4 and 5 were never released. A dropdown in a GUI
would show these four entries.

Patches are differentiated by the MINOR version as int. MINOR versions slightly change the sound.
Typical reasons are retuning, filter changes etc.
The chosen MINOR version stays active until changed by the user. All MINOR versions variant of
an instrument must be available in all future file-releases.

PATCH version levels are just increased, as they are defined to not change the sound outcome.
For example they fix obvious bugs nobody could have wanted, extend the range of an instrument
or introduce new CC controlers for parameters previously not available.
PATCH versions are automatically upgraded. You cannot go back programatically.
The PATCH number is not included in the sfz file name, while major and minor are.

A MAJOR version must be an entirely different file. These are incompatible with older versions.
For example they use a different control scheme (different CC maps)

Besides version there is also the option to just name the sfz file anything you want, as a
special variant. Which is problematic:
What constitues as "Instrument Variant" and what as "New Instrument" must be decided on a case
by case basis. For example a different piano than the salamander is surely a new instrument.
But putting a blanket over the strings (prepared piano) to muffle the sound is the same physical
instrument, but is this a variant? Different microphone or mic position maybe?
This is mostly important when we are not talking about upgrades, which can just use version
numbers, but true "side-grades". I guess the main argument is that you never would want both
variants at the same time. And even if the muffled blanket sound is the same instrument,
this could be integrated as CC switch or fader. Same for the different microphones and positions.
At the time of writing the author was not able to come up with a "sidegrade" usecase,
that isn't either an sfz controller or a different instrument (I personally consider the
blanket-piano a different instrument)"""

allInstruments = {} # (libraryId, instrumentId):Instrument()-object

def __init__(self, parentLibrary, libraryId:int, metadata:dict, tarFilePath:str, startVariantSfzFilename:str=None):
self.parentLibrary = parentLibrary
self.id = metadata["id"]
self.idKey = (libraryId, metadata["id"])
Instrument.allInstruments[self.idKey] = self

self.cboxMidiPortUid = None

self.metadata = metadata #parsed from the ini file. See self.exportMetadata for the pythonic dict
self.tarFilePath = tarFilePath
self.name = metadata["name"]
self.midiInputPortName = metadata["name"]
self.variants = metadata["variants"].split(",")
self.defaultVariant = metadata["defaultVariant"]
self.enabled = True #means loaded.


#Calfbox. The JACK ports are constructed without samples at first.
self.scene = cbox.Document.get_engine().new_scene()
self.scene.clear()
layer = self.scene.add_new_instrument_layer(self.midiInputPortName, "sampler") #"sampler" is the cbox sfz engine
self.scene.status().layers[0].get_instrument().engine.load_patch_from_string(0, "", "", "") #fill with null instruments
self.instrumentLayer = self.scene.status().layers[0].get_instrument()
self.program = None #return object from self.instrumentLayer.engine.load_patch_from_tar
self.scene.status().layers[0].set_ignore_program_changes(1) #TODO: ignore different channels. We only want one channel per scene/instrument/port. #TODO: Add generic filters to filter out redundant tasks like mixing and panning, which should be done in an audio mixer.
#self.instrumentLayer.engine.set_polyphony(int)

#Create Stereo Audio Ouput Ports
#Connect to our own pair but also to a generic mixer port that is in Data()
jackAudioOutLeft = cbox.JackIO.create_audio_output(self.midiInputPortName+"_L")
jackAudioOutRight = cbox.JackIO.create_audio_output(self.midiInputPortName+"_R")

outputMergerRouter = cbox.JackIO.create_audio_output_router(jackAudioOutLeft, jackAudioOutRight)
outputMergerRouter.set_gain(-3.0)
instrument = layer.get_instrument()
instrument.get_output_slot(0).rec_wet.attach(outputMergerRouter) #output_slot is 0 based and means a pair. Most sfz instrument have only one stereo pair. #TODO: And what if not?

routerToGlobalSummingStereoMixer = cbox.JackIO.create_audio_output_router(self.parentLibrary.parentData.lmixUuid, self.parentLibrary.parentData.rmixUuid)
routerToGlobalSummingStereoMixer.set_gain(-3.0)
instrument.get_output_slot(0).rec_wet.attach(routerToGlobalSummingStereoMixer)

#Create Midi Input Port
self.cboxMidiPortUid = cbox.JackIO.create_midi_input(self.midiInputPortName)
cbox.JackIO.set_appsink_for_midi_input(self.cboxMidiPortUid, True) #This sounds like a program wide sink, but it is needed for every port.
cbox.JackIO.route_midi_input(self.cboxMidiPortUid, self.scene.uuid)


self.startVariantSfzFilename = startVariantSfzFilename
self.currentVariant:str = None #set by self.chooseVariant()
#We could call self.load() now, but we delay that for the user experience. See docstring.


def exportStatus(self):
"""The call-often function to get the instrument status. Includes only data that can
actually change during runtime."""
result = {}

#Static ids
result["id"] = self.metadata["id"]
result["id-key"] = self.idKey #redundancy for convenience.

#Dynamic data
result["currentVariant"] = self.currentVariant # str
result["state"] = self.enabled #bool

return result


def exportMetadata(self):
"""This gets called before the samples are loaded.
Only static data, that does not get changed during runtime, is included here.

Please note that we don't add the default variant here. It is only important for the
external world to know what the current variant is. Which is handled by self.exportStatus()
"""
result = {}
result["id"] = self.metadata["id"] #int
result["id-key"] = self.idKey # tuple (int, int) redundancy for convenience.
result["name"] = self.metadata["name"] #str
result["variants"] = self.variants #list of str
result["defaultVariant"] = self.metadata["defaultVariant"] #str
result["tags"] = self.metadata["tags"].split(",") # list of str

#Optional Tags.
result["license"] = self.metadata["license"] if "license" in self.metadata else "" #str
result["vendor"] = self.metadata["vendor"] if "vendor" in self.metadata else "" #str
result["group"] = self.metadata["group"] if "group" in self.metadata else "" #str
return result

def loadSamples(self):
"""Instrument is constructed without loading the sample data. But the JACK Port and
all Python objects exist. The API can instruct the loading when everything is ready,
so that the callbacks can receive load-progress messages"""
if self.startVariantSfzFilename:
self.chooseVariant(self.startVariantSfzFilename)
else:
self.chooseVariant(self.metadata["defaultVariant"])

def chooseVariant(self, variantSfzFileName:str):
"""load_patch_from_tar is blocking. This function will return when the instrument is ready
to play.

The function will do nothing when the instrument is not enabled.
"""

if not self.enabled:
return

if not variantSfzFileName in self.metadata["variants"]:
raise ValueError("Variant not in list: {} {}".format(variantSfzFileName, self.metadata["variants"]))

logger.info(f"Start loading samples for instrument {variantSfzFileName} with id key {self.idKey}")
#help (self.allInstrumentLayers[self.defaultPortUid].engine) #shows the functions to load programs into channels etc.
#newProgramNumber = self.instrumentLayer.engine.get_unused_program()
programNumber = self.metadata["variants"].index(variantSfzFileName) #counts from 1
self.program = self.instrumentLayer.engine.load_patch_from_tar(programNumber, self.tarFilePath, variantSfzFileName, self.metadata["name"])
self.instrumentLayer.engine.set_patch(1, programNumber) #1 is the channel, counting from 1. #TODO: we want this to be on all channels.
self.currentVariant = variantSfzFileName
logger.info(f"Finished loading samples for instrument {variantSfzFileName} with id key {self.idKey}")

def enable(self):
self.enabled = True

def disable(self):
"""Jack midi port and audio ports will disappear. Impact on RAM and CPU usage unknown."""
self.enabled = False

+ 120
- 2
engine/main.py View File

@@ -23,6 +23,10 @@ import logging; logger = logging.getLogger(__name__); logger.info("import")

#Python Standard Lib
import os.path
import configparser
import pathlib
import tarfile
from io import TextIOWrapper

#Third Party
from calfbox import cbox
@@ -31,10 +35,124 @@ from calfbox import cbox
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.
Simply inheriting from engine.data.Data is easiest.

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.
pass
super().__init__(parentSession)


session = self.parentSession

#Create two mixer ports, for stereo. Each instrument will not only create their own jack out ports
#but also connect to these left/right.
assert not session.standaloneMode is None
if session.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')

#Auditioner: Create an additional stereo port pair to pre-listen to on sample instrument alone
self.auditioner = Auditioner(self)

#Parse all tar libraries and load them all. ALL OF THEM!
#for each library in ...
self.libraries = {} # libraryId:int : Library-object
lib = Library(parentData=self, tarFilePath="/home/nils/lss/test-data.tar")
self.libraries[lib.id] = lib


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


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.
"""

def __init__(self, parentData, tarFilePath):#
self.parentData = parentData
self.tarFilePath = pathlib.Path(tarFilePath)
if not tarFilePath.endswith(".tar"):
raise RuntimeError(f"Wrong file {tarFilePath}")

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
"""


self.id = self.config["library"]["id"]
instrumentSections = self.config.sections()
instrumentSections.remove("library")
self.instruments = {} # instrId : Instrument()
for iniSection in instrumentSections:
instrObj = Instrument(self, self.config["library"]["id"], self.config[iniSection], tarFilePath)
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



+ 26
- 8
qtgui/designer/mainwindow.py View File

@@ -14,7 +14,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(894, 836)
MainWindow.resize(1087, 752)
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
@@ -35,16 +35,34 @@ class Ui_MainWindow(object):
self.splitter.setOrientation(QtCore.Qt.Vertical)
self.splitter.setObjectName("splitter")
self.iinstruments_groupBox = QtWidgets.QGroupBox(self.splitter)
self.iinstruments_groupBox.setEnabled(True)
self.iinstruments_groupBox.setObjectName("iinstruments_groupBox")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.iinstruments_groupBox)
self.verticalLayout_2.setContentsMargins(0, 0, 0, 0)
self.verticalLayout_2.setSpacing(0)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.auditionerWidget = QtWidgets.QWidget(self.iinstruments_groupBox)
self.auditionerWidget.setObjectName("auditionerWidget")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.auditionerWidget)
self.horizontalLayout.setObjectName("horizontalLayout")
self.label = QtWidgets.QLabel(self.auditionerWidget)
self.label.setObjectName("label")
self.horizontalLayout.addWidget(self.label)
self.auditionerMidiInputComboBox = QtWidgets.QComboBox(self.auditionerWidget)
self.auditionerMidiInputComboBox.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
self.auditionerMidiInputComboBox.setObjectName("auditionerMidiInputComboBox")
self.horizontalLayout.addWidget(self.auditionerMidiInputComboBox)
self.auditionerCurrentInstrument_label = QtWidgets.QLabel(self.auditionerWidget)
self.auditionerCurrentInstrument_label.setObjectName("auditionerCurrentInstrument_label")
self.horizontalLayout.addWidget(self.auditionerCurrentInstrument_label)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem)
self.verticalLayout_2.addWidget(self.auditionerWidget)
self.instruments_treeWidget = QtWidgets.QTreeWidget(self.iinstruments_groupBox)
self.instruments_treeWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.instruments_treeWidget.setProperty("showDropIndicator", False)
self.instruments_treeWidget.setAlternatingRowColors(True)
self.instruments_treeWidget.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
self.instruments_treeWidget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.instruments_treeWidget.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.instruments_treeWidget.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.instruments_treeWidget.setObjectName("instruments_treeWidget")
@@ -59,14 +77,14 @@ class Ui_MainWindow(object):
self.details_scrollArea.setWidgetResizable(True)
self.details_scrollArea.setObjectName("details_scrollArea")
self.scrollAreaWidgetContents = QtWidgets.QWidget()
self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 428, 208))
self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 545, 158))
self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
self.details_scrollArea.setWidget(self.scrollAreaWidgetContents)
self.verticalLayout_3.addWidget(self.details_scrollArea)
self.verticalLayout.addWidget(self.splitter_2)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 894, 20))
self.menubar.setGeometry(QtCore.QRect(0, 0, 1087, 20))
self.menubar.setObjectName("menubar")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(MainWindow)
@@ -83,11 +101,11 @@ class Ui_MainWindow(object):
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
self.search_groupBox.setTitle(_translate("MainWindow", "Search"))
self.iinstruments_groupBox.setTitle(_translate("MainWindow", "Instruments"))
self.instruments_treeWidget.headerItem().setText(0, _translate("MainWindow", " "))
self.label.setText(_translate("MainWindow", "Auditioner MIDI Input"))
self.auditionerCurrentInstrument_label.setText(_translate("MainWindow", "TextLabel"))
self.instruments_treeWidget.headerItem().setText(1, _translate("MainWindow", "ID"))
self.instruments_treeWidget.headerItem().setText(2, _translate("MainWindow", "Name"))
self.instruments_treeWidget.headerItem().setText(3, _translate("MainWindow", "Version"))
self.instruments_treeWidget.headerItem().setText(4, _translate("MainWindow", "Vendor"))
self.instruments_treeWidget.headerItem().setText(5, _translate("MainWindow", "Logo"))
self.instruments_treeWidget.headerItem().setText(3, _translate("MainWindow", "Variant"))
self.instruments_treeWidget.headerItem().setText(4, _translate("MainWindow", "Tags"))
self.details_groupBox.setTitle(_translate("MainWindow", "NamePlaceholder"))
self.actionRofl.setText(_translate("MainWindow", "Rofl!"))

+ 52
- 14
qtgui/designer/mainwindow.ui View File

@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>894</width>
<height>836</height>
<width>1087</width>
<height>752</height>
</rect>
</property>
<property name="windowTitle">
@@ -50,6 +50,9 @@
<enum>Qt::Vertical</enum>
</property>
<widget class="QGroupBox" name="iinstruments_groupBox">
<property name="enabled">
<bool>true</bool>
</property>
<property name="title">
<string>Instruments</string>
</property>
@@ -69,6 +72,46 @@
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="auditionerWidget" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Auditioner MIDI Input</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="auditionerMidiInputComboBox">
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="auditionerCurrentInstrument_label">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="instruments_treeWidget">
<property name="editTriggers">
@@ -81,7 +124,7 @@
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::NoSelection</enum>
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
@@ -91,7 +134,7 @@
</property>
<column>
<property name="text">
<string> </string>
<string/>
</property>
</column>
<column>
@@ -106,17 +149,12 @@
</column>
<column>
<property name="text">
<string>Version</string>
</property>
</column>
<column>
<property name="text">
<string>Vendor</string>
<string>Variant</string>
</property>
</column>
<column>
<property name="text">
<string>Logo</string>
<string>Tags</string>
</property>
</column>
</widget>
@@ -153,8 +191,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>428</width>
<height>208</height>
<width>545</width>
<height>158</height>
</rect>
</property>
</widget>
@@ -172,7 +210,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>894</width>
<width>1087</width>
<height>20</height>
</rect>
</property>

+ 182
- 39
qtgui/instrument.py View File

@@ -32,6 +32,10 @@ from template.qtgui.helper import ToggleSwitch
#Engine
import engine.api as api


COLUMNS = ("state", "id-key", "name", "loaded", "group", "tags" ) #Loaded = Variant


class InstrumentTreeController(object):
"""Not a qt class. We externally controls the QTreeWidget

@@ -45,42 +49,159 @@ class InstrumentTreeController(object):
self.parentMainWindow = parentMainWindow
self.treeWidget = self.parentMainWindow.ui.instruments_treeWidget


self.guiInstruments = {} # id-key : GuiInstrument


self.headerLabels = [
" ",
QtCore.QCoreApplication.translate("InstrumentTreeController", "ID"),
QtCore.QCoreApplication.translate("InstrumentTreeController", "Name"),
QtCore.QCoreApplication.translate("InstrumentTreeController", "Loaded"),
QtCore.QCoreApplication.translate("InstrumentTreeController", "Group"),
QtCore.QCoreApplication.translate("InstrumentTreeController", "Tags"),
]
self.treeWidget.setColumnCount(len(self.headerLabels))
self.treeWidget.setHeaderLabels(self.headerLabels)
self.treeWidget.setSortingEnabled(True)
self.treeWidget.setAlternatingRowColors(True)
self.treeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.treeWidget.header().setSortIndicator(0,0)

self.treeWidget.itemDoubleClicked.connect(self.itemDoubleClicked)
#self.treeWidget.itemSelectionChanged = self.itemSelectionChanged

self.sortByColumnValue = 1 #by instrId
self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending

self._testData()
api.callbacks.instrumentListMetadata.append(self.buildTree)
api.callbacks.startLoadingSamples.append(self.react_startLoadingSamples)
api.callbacks.instrumentStatusChanged.append(self.react_instrumentStatusChanged)


def itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int):
if type(item) is GuiInstrument:
api.auditionerInstrument(item.idkey)


def buildTree(self, data:dict):
"""Data is a dict of dicts and has a hierarchy.
data[libId] = dictOfInstruments
dictOfInstrument[instrId] = pythonDataDict

There is one special "library" key dictOfInstrument["library"] that has metadata for
the lib itself. We use that to construct the top level item and sort all others inside

Example data

{'0': {'0': {'group': 'strings',
'id': '0',
'id-key': ('0', '0'),
'license': 'https://unlicense.org/',
'name': 'Sine Wave',
'tags': ['sine', 'basic'],
'variants': ['Sine.sfz', 'Sine With 5th Slide.sfz'],
'vendor': 'Test entry to provide more vendor information'},
'1': {'group': 'strings',
'id': '1',
'id-key': ('0', '1'),
'license': '',
'name': 'Square Wave',
'tags': ['square', 'basic'],
'variants': ['Square.sfz', 'Square With 5th Slide.sfz'],
'vendor': ''},
'2': {'group': 'brass',
'id': '2',
'id-key': ('0', '2'),
'license': '',
'name': 'Saw Wave',
'tags': ['saw', 'basic'],
'variants': ['Saw.sfz', 'Saw With 5th Slide.sfz'],
'vendor': ''},
'3': {'group': '',
'id': '3',
'id-key': ('0', '3'),
'license': '',
'name': 'Triangle Wave',
'tags': ['triangle', 'complex'],
'variants': ['Triangle.sfz', 'Triangle With 5th Slide.sfz'],
'vendor': ''},
'library': {'description': 'Basic waveforms. Actual non-looping '
'samples, created by sox. No sfz '
'synth-engine involved. There is a variant '
'with an additional sound-blip for each wave '
'form, which are purely there as technical '
'example. They are not a guideline what '
'constitues a variant and what a different '
'instrument.',
'id': '0',
'license': 'https://creativecommons.org/publicdomain/zero/1.0/',
'name': 'Tembro Test Instruments',
'tarFilePath': PosixPath('/home/nils/lss/test-data.tar'),
'vendor': 'Hilbricht Nils 2021, Laborejo Software Suite '
'https://www.laborejo.org info@laborejo.org'}}}
"""

for libraryId, libraryDict in data.items():

parentLibraryWidget = GuiLibrary(parentTreeController=self, libraryDict=libraryDict["library"])
self.treeWidget.addTopLevelItem(parentLibraryWidget)

for instrumentdId, instrumentDict in libraryDict.items():
if instrumentdId == "library":
#Top level item was already created. Ignore here.
pass
else:
self.newInstrument(parentLibraryWidget, instrumentDict)

parentLibraryWidget.setExpanded(True)

def newInstrument(self, instrumentDict):
self._adjustColumnSize()


def newInstrument(self, parentLibraryWidget, instrumentDict):
gi = GuiInstrument(parentTreeController=self, instrumentDict=instrumentDict)
self.treeWidget.addTopLevelItem(gi)
#self.treeWidget.addTopLevelItem(gi)
parentLibraryWidget.addChild(gi)
gi.injectToggleSwitch() #only possible after gi.init was done and item inserted.
self._adjustColumnSize()

def _testData(self):
exampleInstrumentDict1 = {
"instrId" : 123,
"state": False,
"prettyName" : "My Instrument 2000",
"vendor" : "Nils Productions",
"version" : "1.0.3",
"logo" : None,
}
exampleInstrumentDict2 = {
"instrId" : 124,
"state": False,
"prettyName" : "Untuned Guitar",
"vendor" : "Merle Instrument Design",
"version" : "0.4.1",
"logo" : None,
}

self.newInstrument(exampleInstrumentDict1)
self.newInstrument(exampleInstrumentDict2)
self.guiInstruments[instrumentDict["id-key"]] = gi

def _adjustColumnSize(self):
self.treeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
stateIndex = COLUMNS.index("state")
for index in range(self.treeWidget.columnCount()):
self.treeWidget.resizeColumnToContents(index)
if not index == stateIndex:
self.treeWidget.resizeColumnToContents(index)
self.treeWidget.setColumnWidth(index, self.treeWidget.columnWidth(index)+15) #add padding

#Fixed width for the switch
self.treeWidget.setColumnWidth(stateIndex, 80)


def react_instrumentStatusChanged(self, instrumentStatus:dict):
gi = self.guiInstruments[instrumentStatus["id-key"]]
gi.updateStatus(instrumentStatus)


def react_startLoadingSamples(self, idkey:tuple):
"""Will be overriden by instrument status change / variant chosen"""
text = QtCore.QCoreApplication.translate("InstrumentTreeController", "…loading…")
loadedIndex = COLUMNS.index("loaded")
instr = self.guiInstruments[idkey]
instr.setText(loadedIndex, text)
self.parentMainWindow.qtApp.processEvents() #actually show the label

class GuiLibrary(QtWidgets.QTreeWidgetItem):
"""The top level library item. All instruments are in a library."""

def __init__(self, parentTreeController, libraryDict):
super().__init__([], type=1000) #type 0 is default qt type. 1000 is subclassed user type)

#No dynamic data here. Everything gets created once.
self.setText(COLUMNS.index("id-key"), str(libraryDict["id"]))
self.setText(COLUMNS.index("name"), str(libraryDict["name"]))
self.setText(COLUMNS.index("tags"), str(libraryDict["description"]))


class GuiInstrument(QtWidgets.QTreeWidgetItem):
@@ -91,26 +212,31 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem):
All data is received at program start. No new items will be created, none will get deleted.
All instruments in Tembro are static.

By default all instruments are switched off. The user can switch them on/off here or
a loaded save state will send the state on program start.
Most parameters we receive are read only, like instrId, name and version

Not all parameters are used in the TreeWidgetItem. e.g. Description, Vendor and License are only
for the details view.
"""

Most parameters we receive are read only, like instrId, prettyName and version"""

allItems = {} # instrId : GuiInstrument

def __init__(self, parentTreeController, instrumentDict):
GuiInstrument.allItems[instrumentDict["instrId"]] = self
GuiInstrument.allItems[instrumentDict["id-key"]] = self
self.parentTreeController = parentTreeController
self.idkey = instrumentDict["id-key"]

#Start with empty columns. We fill in later in _writeColumns
super().__init__([], type=1000) #type 0 is default qt type. 1000 is subclassed user type)


self.columns = ("state" , "instrId", "prettyName", "version", "vendor", "logo") #Same keys as instrumentDict. Keep in sync manually with Qt Designer. All code must use this, never number directly.
self.columns = COLUMNS
#Use with:
#nameColumnIndex = self.columns.index("prettyName")
#self.setText(nameColumnIndex, "hello")

self.setTextAlignment(self.columns.index("id-key"), QtCore.Qt.AlignHCenter)

self.state = None #by self.switch()
self.instrumentDict = None
self._writeColumns(instrumentDict)
@@ -122,36 +248,53 @@ class GuiInstrument(QtWidgets.QTreeWidgetItem):
self.toggleSwitch.pressed.connect(lambda: print('pressed'))
self.toggleSwitch.released.connect(lambda: print('released'))


#We cannot add the ToggleSwitch Widget here.
#It must be inserted after self was added to the Tree. Use self.injectToggleSwitch from parent


def updateStatus(self, instrumentStatus:dict):
variantColumnIndex = self.columns.index("loaded")
self.currentVariant = instrumentStatus["currentVariant"]
if instrumentStatus["currentVariant"]: #None if not loaded or not enabled anymore
self.setText(variantColumnIndex, instrumentStatus["currentVariant"].rstrip(".sfz"))
self.state = instrumentStatus["state"]
self.toggleSwitch.setChecked(instrumentStatus["state"])

def injectToggleSwitch(self):
"""Call this after the item was added to the tree"""
stateColumnIndex = self.columns.index("state")
self.parentTreeController.treeWidget.setItemWidget(self, stateColumnIndex, self.toggleSwitch)

def _writeColumns(self, instrumentDict):
"""This is used to construct the columns when the program starts. There is an
update callback for dynamic values as well"""

self.instrumentDict = instrumentDict

for index, key in enumerate(self.columns):
value = instrumentDict[key]
QtCore.QCoreApplication.translate("OpenSession", "not saved")

if type(instrumentDict[key]) is str or key == "instrId":
self.setText(index, str(instrumentDict[key]))
if key == "state" or key == "loaded":
pass #this arrives through a different api.callback

elif type(instrumentDict[key]) is str:
self.setText(index, str(instrumentDict[key]))

elif key == "tags": #list
t = ", ".join(instrumentDict[key])
self.setText(index, t)

elif key == "logo":
pass
elif key == "id-key": #tuple
libId, instrId = instrumentDict[key]
self.setText(index, f"{libId}-{instrId}")

"""
elif key == "state": #use parameter for initial value. loaded from file or default = False.
state = instrumentDict[key]
assert type(state) is bool, state
self.switch(state)




"""

def switch(self, state:bool):
"""This is not the Qt function but if an instrument is enabled, loaded to RAM and ready to

+ 44
- 0
qtgui/mainwindow.py View File

@@ -66,6 +66,7 @@ class MainWindow(TemplateMainWindow):

self.setupMenu()

self.auditionerMidiInputComboController = AuditionerMidiInputComboController(parentMainWindow=self)
self.instrumentTreeController = InstrumentTreeController(parentMainWindow=self)

self.start() #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange.
@@ -76,6 +77,7 @@ class MainWindow(TemplateMainWindow):
#New menu entries and template-menu overrides
#self.menu.connectMenuEntry("actionAbout", lambda: print("About Dialog Menu deactivated")) #deactivates the original function
self.menu.addMenuEntry("menuEdit", "actionNils", "Nils", lambda: print("Merle"))
self.menu.addMenuEntry("menuEdit", "actionLoadSamples", "LoadSamples", api.loadSamples)
#self.menu.connectMenuEntry("actionNils", lambda: print("Override"))

def zoom(self, scaleFactor:float):
@@ -83,3 +85,45 @@ class MainWindow(TemplateMainWindow):
def stretchXCoordinates(self, factor):
pass

class AuditionerMidiInputComboController(object):

def __init__(self, parentMainWindow):
self.parentMainWindow = parentMainWindow
self.comboBox = parentMainWindow.ui.auditionerMidiInputComboBox
self.wholePanel = parentMainWindow.ui.auditionerWidget
self.currentInstrumentLabel = parentMainWindow.ui.auditionerCurrentInstrument_label
self.currentInstrumentLabel.setText("")

#if not api.isStandaloneMode():
#self.wholePanel.hide()
#return

self.wholePanel.show() #explicit is better than implicit
self.originalShowPopup = self.comboBox.showPopup
self.comboBox.showPopup = self.showPopup
self.comboBox.activated.connect(self._newPortChosen)

api.callbacks.auditionerInstrumentChanged.append(self.callback_auditionerInstrumentChanged)

def callback_auditionerInstrumentChanged(self, exportMetadata:dict):
key = exportMetadata["id-key"]
t = f"➜ [{key[0]}-{key[1]}] {exportMetadata['name']}"
self.currentInstrumentLabel.setText(t)


def _newPortChosen(self, index:int):
assert self.comboBox.currentIndex() == index
api.connectAuditionerPort(self.comboBox.currentText())

def showPopup(self):
"""When the combobox is opened quickly update the port list before showing it"""
self._fill()
self.originalShowPopup()

def _fill(self):
self.comboBox.clear()
availablePorts = api.getAvailableAuditionerPorts()
self.comboBox.addItems(availablePorts["hardware"])
#self.comboBox.insertSeparator(len(availablePorts["hardware"])+1)
self.comboBox.addItem("") # Not only a more visible seaparator than the Qt one, but also doubles as "disconnect"
self.comboBox.addItems(availablePorts["software"])

+ 0
- 16086
template/calfbox/configure~
File diff suppressed because it is too large
View File


+ 3
- 0
template/engine/api.py View File

@@ -308,6 +308,9 @@ def startEngine(nsmClient):
logger.info("Template api engine started")


def isStandaloneMode():
return session.standaloneMode

def _deprecated_updatePlayback():
"""The only place in the program to update the cbox playback besides startEngine.
We only need to update it after a user action, which always goes through the api.

Loading…
Cancel
Save