Sampled Instrument Player with static and monolithic design. All instruments are built-in.
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.

535 lines
25 KiB

3 years ago
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
3 years ago
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")
#Standard Library
#Third Party
from PyQt5 import QtCore, QtGui, QtWidgets
#Our Qt
from template.qtgui.helper import ToggleSwitch,FancySwitch
3 years ago
#Engine
import engine.api as api
COLUMNS = ("state", "id-key", "mixSend", "name", "loaded", "group", "tags" ) #Loaded = Variant
3 years ago
class InstrumentTreeController(object):
"""
Shows the list of instruments, so they can be clicked upon :)
Not a qt class. We externally controls the QTreeWidget
3 years ago
Why is this not a QTableWidget? As in Agordejo, a TableWidget is a complex item, and inconvenient
to use. You need to add an Item to each cell. While in TreeWidget you just create one item.
And we might use the TreeView to group by manufacturer etc. Eventhough we have a Filter.
"""
def __init__(self, parentMainWindow):
self.parentMainWindow = parentMainWindow
self.treeWidget = self.parentMainWindow.ui.instruments_treeWidget
self._cachedData = None
self._cachedLastInstrumentStatus = {} # instrument idkey : status Dict
self.guiLibraries = {} # id-key : GuiLibrary
self.guiInstruments = {} # id-key : GuiInstrument
self.currentlyNested = None #is the view nested in libraries or just all instruments?
self.headerLabels = [
QtCore.QCoreApplication.translate("InstrumentTreeController", "Enable"),
QtCore.QCoreApplication.translate("InstrumentTreeController", "ID"),
QtCore.QCoreApplication.translate("InstrumentTreeController", "toMix"),
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.connect(self.itemSelectionChanged)
self.treeWidget.itemExpanded.connect(self.itemExpandedOrCollapsed)
self.treeWidget.itemCollapsed.connect(self.itemExpandedOrCollapsed)
3 years ago
self.sortByColumnValue = 1 #by instrId
self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
api.callbacks.instrumentListMetadata.append(self.buildTree) #called without arguments it will just create the standard tree. This will only happen once at program start.
api.callbacks.startLoadingSamples.append(self.react_startLoadingSamples)
api.callbacks.instrumentStatusChanged.append(self.react_instrumentStatusChanged)
api.callbacks.instrumentMidiNoteOnActivity.append(self.react_instrumentMidiNoteOnActivity)
#self.treeWidget.setStyleSheet("QTreeWidget::item { border-bottom: 1px solid black;}") #sadly for all items. We want a line below top level items.
if not "libraryIsExpanded" in api.session.guiSharedDataToSave:
api.session.guiSharedDataToSave["libraryIsExpanded"] = {} # libId : bool if expanded or not. Also used when switching from nested to flat and back.
#Default values are used in self.buildTree
def itemExpandedOrCollapsed(self, libraryItem:QtWidgets.QTreeWidgetItem):
#print (libraryItem.name, libraryItem.isExpanded())
api.session.guiSharedDataToSave["libraryIsExpanded"][libraryItem.id] = libraryItem.isExpanded()
def itemSelectionChanged(self):
"""Only one instrument can be selected at the same time.
This function mostly informs other widgets that a different instrument was selected
"""
selItems = self.treeWidget.selectedItems()
if not selItems:
return
assert len(selItems) == 1, selItems #because our selection is set to single.
item = selItems[0]
if type(item) is GuiInstrument:
self.parentMainWindow.selectedInstrumentController.instrumentChanged(item.idkey)
else:
self.parentMainWindow.selectedInstrumentController.directLibrary(item.idkey)
def itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int):
"""This chooses the auditioner. Callbacks are handled in the auditioner widget itself"""
if type(item) is GuiInstrument:
api.auditionerInstrument(item.idkey)
def buildTree(self, data:dict, nested:bool=None):
"""
Create the tree. Can be called multiple times and it will re-create itself destructively.
If you call it with data, once at program start, data will get cached. If you call
with data=None it will used the cached variant.
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'}}}
"""
#Reset everything except our cached data.
self.treeWidget.clear() #will delete the C++ objects. We need to delete the PyQt objects ourselves, like so:
self.guiLibraries = {} # id-key : GuiLibrary
self.guiInstruments = {} # id-key : GuiInstrument
self.currentlyNested = nested
if data:
self._cachedData = data
else:
assert self._cachedData
data = self._cachedData
if nested is None:
if "nestedView" in api.session.guiSharedDataToSave:
nested = api.session.guiSharedDataToSave["nestedView"]
else:
nested = True
api.session.guiSharedDataToSave["nestedView"] = nested
self.currentlyNested = nested
for libraryId, libraryDict in data.items():
if nested:
parentLibraryWidget = GuiLibrary(parentTreeController=self, libraryDict=libraryDict["library"])
self.guiLibraries[libraryId] = parentLibraryWidget
self.treeWidget.addTopLevelItem(parentLibraryWidget)
if libraryId in api.session.guiSharedDataToSave["libraryIsExpanded"]:
parentLibraryWidget.setExpanded(api.session.guiSharedDataToSave["libraryIsExpanded"][libraryId]) #only possible after gi.init() was done and item inserted.
#parentLibraryWidget.setHidden(True) #only possible after insert
for instrumentdId, instrumentDict in libraryDict.items():
if instrumentdId == "library":
#Top level item was already created. Ignore here.
pass
else:
gi = GuiInstrument(parentTreeController=self, instrumentDict=instrumentDict)
if nested:
parentLibraryWidget.addChild(gi)
else:
self.treeWidget.addTopLevelItem(gi)
gi.injectWidgets() #only possible after gi.init() was done and item inserted.
self.guiInstruments[instrumentDict["id-key"]] = gi
if instrumentDict["id-key"] in self._cachedLastInstrumentStatus:
gi.updateStatus(self._cachedLastInstrumentStatus[instrumentDict["id-key"]])
3 years ago
self._adjustColumnSize()
def toggleNestedFlat(self, newCheckStateIsNested):
"""We receive newCheckState as automatic parameter from Qt from the calling menu action"""
self.buildTree(data=None, nested=newCheckStateIsNested) #with data=None it will used the cache data we received once, at startup
def setAllExpanded(self, state:bool):
"""We do not use the qt function collapseAll and expandAll because they do not trigger
the signal"""
if self.currentlyNested:
for libid, guiLib in self.guiLibraries.items():
guiLib.setExpanded(state) #triggers signal which will trigger self.toggleNestedFlat
3 years ago
def _adjustColumnSize(self):
self.treeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
stateIndex = COLUMNS.index("state")
3 years ago
for index in range(self.treeWidget.columnCount()):
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):
self.parentMainWindow.qtApp.restoreOverrideCursor() #Sometimes the instrument was loaded with a cursor animation
gi = self.guiInstruments[instrumentStatus["id-key"]]
gi.updateStatus(instrumentStatus)
self._adjustColumnSize()
#We also cache the last status, as we cache the initial data. This way we can delete and recreate TreeItems without requesting new status data from the engine
self._cachedLastInstrumentStatus[instrumentStatus["id-key"]] = instrumentStatus
def react_startLoadingSamples(self, idkey:tuple):
"""Will be overriden by instrument status change / variant chosen"""
self.parentMainWindow.qtApp.setOverrideCursor(QtCore.Qt.WaitCursor) #reset in self.react_instrumentStatusChanged
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 and cursor
def react_instrumentMidiNoteOnActivity(self, idkey:tuple):
#First figure out which instrument has activity
gi = self.guiInstruments[idkey]
gi.activity()
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)
self.id = libraryDict["id"]
self.idkey = (libraryDict["id"], 0) #fake it for compatibility
self.name = libraryDict["name"]
#No dynamic data here. Everything gets created once.
#self.setText(COLUMNS.index("id-key"), str(libraryDict["id"]).zfill(leadingZeroesForZfill))
self.setData(COLUMNS.index("id-key"), 0, int(libraryDict["id"])) #set data allows sorting by actual numbers. 0 is the data role, which is just "display text".
self.setText(COLUMNS.index("name"), str(libraryDict["name"]))
self.setText(COLUMNS.index("tags"), str(libraryDict["description"])[:42]+"")
3 years ago
#Hack the row height through an unused column.
#self.setText(COLUMNS.index("loaded"), "")
#We cannot call setExpanded here. The item must first be inserted into the parent tree.
#We placed this call in InstrumentTreeController.buildTree
#self.setExpanded(False)
icon = parentTreeController.parentMainWindow.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DirIcon"))
self.setIcon(COLUMNS.index("name"), icon)
3 years ago
class GuiInstrument(QtWidgets.QTreeWidgetItem):
"""
Why is this not a QTableWidget? As in Agordejo, a TableWidget is a complex item, and inconvenient
to use. You need to add an Item to each cell. While in TreeWidget you just create one item.
All data is received at program start. No new items will be created, none will get deleted.
All instruments in Tembro are static.
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.
"""
3 years ago
allItems = {} # instrId : GuiInstrument
3 years ago
def __init__(self, parentTreeController, instrumentDict):
GuiInstrument.allItems[instrumentDict["id-key"]] = self
3 years ago
self.parentTreeController = parentTreeController
self.idkey = instrumentDict["id-key"]
3 years ago
#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 = COLUMNS
3 years ago
#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.update...
3 years ago
self.instrumentDict = None
self._writeColumns(instrumentDict)
self.mutedMixSendStylesheet = """
QSlider::handle:horizontal {
background: #5c0000;
border-radius: 4px;
height: 8px;
}
"""
self.mixSendDial = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.mixSendDial.setMaximumSize(QtCore.QSize(48, 16))
self.mixSendDial.setPageStep(3)
self.mixSendDial.setMaximum(0)
self.mixSendDial.setMinimum(-21)
self.mixSendDial.setValue(-3)
self.mixSendDial.valueChanged.connect(self._sendVolumeChangeToEngine)
self.mixSendDial.enterEvent = lambda ev: self.parentTreeController.parentMainWindow.statusBar().showMessage((QtCore.QCoreApplication.translate("Instrument", "Use mousewheel to change the instruments mixSend for the stereo mixer ouput. Right click to (un)mute mixer-send.")))
self.mixSendDial.leaveEvent = lambda ev: self.parentTreeController.parentMainWindow.statusBar().showMessage("")
self.mixSendDial.contextMenuEvent = self._mixSendDialContextMenuEvent
self.mixSendDial.setEnabled(False) #default is off, so we don't send mixSend changes for an unloaded instrument
self.toggleSwitch = FancySwitch(track_radius=8,thumb_radius=7) #radius is the actual size, not the rounded corners.
#self.toggleSwitch.toggled.connect(lambda c: print('toggled', c)) #triggered by engine callback as well
self.toggleSwitch.setAutoFillBackground(True) #otherwise conflicts with setItemWidget
self.toggleSwitch.clicked.connect(self.instrumentSwitchOnViaGui)
#self.toggleSwitch.pressed.connect(lambda: print('pressed')) #literal mouse down.
#self.toggleSwitch.released.connect(lambda: print('released'))
3 years ago
#We cannot add the ToggleSwitch Widget here.
#It must be inserted after self was added to the Tree. Use self.injectToggleSwitch from parent
#icon = parentTreeController.parentMainWindow.style().standardIcon(getattr(QtWidgets.QStyle, "SP_ComputerIcon"))
#px = QtGui.QPixmap(32,32) #TODO: get size from standard icon above. but how? Apparently it does not matter how big this is.
#px.fill(QtGui.QColor(255,255,255,0)) #transparent icon)
#icon = QtGui.QIcon(px)
#self.setIcon(COLUMNS.index("name"), icon)
#Create icons for midi in status
on = QtGui.QPixmap(32,32)
oncolor = QtGui.QColor("cyan")
on.fill(oncolor)
self.onIcon = QtGui.QIcon(on)
off = QtGui.QPixmap(32,32)
#offcolor = QtGui.QColor(50, 50, 50) #dark grey
offcolor = QtGui.QColor(255,255,255,0) #transparent
off.fill(offcolor)
self.offIcon = QtGui.QIcon(off)
self.setIcon(COLUMNS.index("name"), self.offIcon)
self.midiActiveFlag = False #midi indicator
3 years ago
def instrumentSwitchOnViaGui(self, state):
"""Only GUI clicks. Does not react to the engine callback that switches on instruments. For
example one that arrives through "load group" or "load all" """
if state:
api.loadInstrumentSamples(self.idkey)
else:
api.unloadInstrumentSamples(self.idkey)
def updateStatus(self, instrumentStatus:dict):
#Before we set the state permanently we use the opportunity to see if this is program state (state == None) or unloading
self._cachedInstrumentStatus = instrumentStatus
firstLoad = self.state is None
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")) #either "" or a variant
self.state = instrumentStatus["state"]
self.toggleSwitch.setChecked(instrumentStatus["state"])
self.mixSendDial.setStyleSheet("")
self.mixSendDial.setUpdatesEnabled(True)
if self.state:
#either reload or load for the first time
self.mixSendDial.setEnabled(True)
self.mixSendDial.setValue(instrumentStatus["mixerLevel"])
if instrumentStatus["mixerEnabled"]:
muteText = ""
else:
muteText = QtCore.QCoreApplication.translate("Instrument", "[Muted]")
self.mixSendDial.setStyleSheet(self.mutedMixSendStylesheet)
self.parentTreeController.parentMainWindow.statusBar().showMessage((QtCore.QCoreApplication.translate("Instrument", "{} send to mix volume: {} {}".format(self.instrumentDict["name"], instrumentStatus["mixerLevel"], muteText))))
api.session.eventLoop.verySlowConnect(self._activityOff)
elif not firstLoad: #and not self.state
#the instrument was once loaded and is currently connected
api.session.eventLoop.verySlowDisconnect(self._activityOff)
if not self.state: #in any not-case
self.mixSendDial.setEnabled(False)
self.mixSendDial.setUpdatesEnabled(False) #this is a hack to make the widget disappear. Because hiding a QTreeWidgetItem does not work.
self.parentTreeController.parentMainWindow.qtApp.processEvents() #actually show the new state, even if mass unloading (which did not work before. But loading worked!)
def _mixSendDialContextMenuEvent(self, event):
if self._cachedInstrumentStatus["mixerEnabled"]:
mixerMuteText = QtCore.QCoreApplication.translate("InstrumentMixerLevelContextMenu", "Mute/Disable Mixer-Send for {}".format(self.instrumentDict["name"]))
mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idkey, False)
else:
mixerMuteText = QtCore.QCoreApplication.translate("InstrumentMixerLevelContextMenu", "Unmute/Enable Mixer-Send for {}".format(self.instrumentDict["name"]))
mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idkey, True)
listOfLabelsAndFunctions = [
(mixerMuteText, mixerMuteFunc),
]
menu = QtWidgets.QMenu()
for text, function in listOfLabelsAndFunctions:
if function is None:
l = QtWidgets.QLabel(text)
l.setAlignment(QtCore.Qt.AlignCenter)
a = QtWidgets.QWidgetAction(menu)
a.setDefaultWidget(l)
menu.addAction(a)
else:
a = QtWidgets.QAction(text, menu)
menu.addAction(a)
a.triggered.connect(function)
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
menu.exec_(pos)
def injectWidgets(self):
"""Call this after the item was added to the tree. Widgets must be inserted after the Item
was created"""
3 years ago
stateColumnIndex = self.columns.index("state")
self.parentTreeController.treeWidget.setItemWidget(self, stateColumnIndex, self.toggleSwitch)
mixSendColumnIndex = self.columns.index("mixSend")
self.parentTreeController.treeWidget.setItemWidget(self, mixSendColumnIndex, self.mixSendDial)
3 years ago
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"""
3 years ago
self.instrumentDict = instrumentDict
for index, key in enumerate(self.columns):
QtCore.QCoreApplication.translate("OpenSession", "not saved")
if key == "state" or key == "loaded" or key == "mixSend":
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)
3 years ago
elif key == "id-key": #tuple
libId, instrId = instrumentDict[key]
zeros = int(instrumentDict["instrumentsInLibraryCount"]/10)+1
instIdZFilled = str(instrId).zfill(zeros)
if self.parentTreeController.currentlyNested:
self.setText(index, instIdZFilled)
else: #full id
#self.setText(index, f"{libId}-{str(instrId).zfill(zeros)}")
3 years ago
self.setData(index, 0, float(str(libId) + "." + instIdZFilled)) #0 is the data role, just standard display text. We combine both IDs to a float number for sorting. If used with setData instead of setText Qt will know how to sort 11 before 1000
3 years ago
"""
3 years ago
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)
"""
3 years ago
def _activityOff(self):
"""Called by a timer"""
if self.midiActiveFlag:
self.midiActiveFlag = False
self.setIcon(COLUMNS.index("name"), self.offIcon)
3 years ago
def activity(self):
"""Show midi note ons as flashing light."""
self.midiActiveFlag = True
self.setIcon(COLUMNS.index("name"), self.onIcon)
def _sendVolumeChangeToEngine(self, newValue):
self.mixSendDial.blockSignals(True)
api.setInstrumentMixerVolume(self.idkey, newValue)
self.mixSendDial.blockSignals(False)