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.
 
 

420 lines
20 KiB

#! /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 <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Standard Library Modules
import os.path
#Third Party Modules
from PyQt5 import QtWidgets, QtCore, QtGui
#Template Modules
from template.qtgui.mainwindow import MainWindow as TemplateMainWindow
from template.qtgui.menu import Menu
from template.qtgui.about import About
from template.qtgui.midiinquickwidget import QuickMidiInputComboController
#Client modules
import engine.api as api
from engine.config import METADATA
class MainWindow(TemplateMainWindow):
def __init__(self):
"""The order of calls is very important.
THe split ploint is calling the super.__init. Some functions need to be called before,
some after.
For example:
The about dialog is created in the template main window init. So we need to set additional
help texts before that init.
The menu needs the template main window to exist
so it can reference the ui.menuActions.
"""
#Inject more help texts in the templates About "Did You Know" field.
############################
#About.didYouKnow is a class variable.
#Make the first three words matter!
#Do not start them all with "You can..." or "...that you can", in response to the Did you know? title.
About.didYouKnow = [
QtCore.QCoreApplication.translate("About", "Block incoming midi instrument changes by checking 'Ignore MIDI Bank and Progam Changes' in the main window. This will make your carefully selected setup very reliable."),
QtCore.QCoreApplication.translate("About", "A short flash of color indicates which channel just received a note."),
] + About.didYouKnow
#Init the templates main window
############################
super().__init__()
#Set up the custom interface
############################
self.highlightCyanPalette = QtGui.QPalette(self.fPalBlue)
self.highlightCyanPalette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Window, QtGui.QColor("cyan"))
self.highlightCyanPalette.setColor(QtGui.QPalette.Active, QtGui.QPalette.Window, QtGui.QColor("cyan"))
self.highlightCyanPalette.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Window, QtGui.QColor("cyan"))
self.ui.label_soundfont_name.hide() #will be shown after loading an sf2
self.ui.checkBox_ignoreProgramChanges.hide() #will be shown after loading an sf2
self.ui.checkBox_ignoreProgramChanges.stateChanged.connect(api.setIgnoreProgramAndBankChanges) #one parameter: int state. 0 is unchecked, 1 is partially, 2 is checked
self.ui.checkBox_playTestAfterSelectingProgram.hide() #will be shown after loading an sf2. The state is queried by the comboBoxProgram directly
if "playTestAfterSelectingProgram" in api.session.guiSharedDataToSave:
self.ui.checkBox_playTestAfterSelectingProgram.setChecked(api.session.guiSharedDataToSave["playTestAfterSelectingProgram"])
self.ui.checkBox_playTestAfterSelectingProgram.stateChanged.connect(self.saveCheckBox_playTestAfterSelectingProgram) #one parameter: int state. 0 is unchecked, 1 is partially, 2 is checked
midiInputWidget = QuickMidiInputComboController(self)
layout = self.ui.verticalLayout
layout.insertWidget(3,midiInputWidget)
#Set up the Rack Tree Widget that holds all Channels
####################################################
self.rackTreeWidget = self.ui.rack
self.rackTreeWidget.setIconSize(QtCore.QSize(16,16))
#self.rackTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
#self.rackTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu)
self.rackTreeWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) #no user editing of the tree widget itself
self.rackTreeWidgetColumns = ("Channel", "Play Test", "Bank", "Program") #basically an enum
self.rackHeaderLabels = [
QtCore.QCoreApplication.translate("RackTreeWidget", "Channel"),
QtCore.QCoreApplication.translate("RackTreeWidget", "Play Test"),
QtCore.QCoreApplication.translate("RackTreeWidget", "Bank"),
QtCore.QCoreApplication.translate("RackTreeWidget", "Program"),
]
self.rackTreeWidget.setHeaderLabels(self.rackHeaderLabels)
self.rackTreeWidget.setSortingEnabled(False)
self.rackTreeWidget.setAlternatingRowColors(True)
self.channels = []
for i in range(16):
#We set up empty channel items first and update them later when a soundfont gets loaded or changed
channelTreeItem = ChannelTreeItem(mainWindow=self, number=i+1) #Channels are 1 based
self.channels.append(channelTreeItem)
self.rackTreeWidget.addTopLevelItem(channelTreeItem)
#ItemWidgets can only be created after the opLevelItem has been fully created and inserted.
channelTreeItem.initSubWidgets()
#Api Callbacks
#Must be registered before startEngine, which is in super.__init__
############################
api.callbacks.soundfontChanged.append(self.callback_SoundFontLoaded)
api.callbacks.channelChanged.append(self.callback_channelChanged)
api.callbacks.channelActivity.append(self.callback_channelActivity)
api.callbacks.ignoreProgramChangesChanged.append(self.callback_ignoreProgramChangesChanged)
#New menu entries and template-menu overrides
self.menu.connectMenuEntry("actionOpen_sf2_Soundfont", self.openSoundfontDialog)
#Show and parse the options to autconnect the mixer ports, but only when not in NSM mode
#We will send the var autoconnectMixer to the engine in any case, but it will default to False.
autoconnectMixer = False
if api.isStandaloneMode():
self.menu.addSubmenu("menuSettings", QtCore.QCoreApplication.translate("mainWindow", "Settings"))
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("autoconnectMixer"):
autoconnectMixer = settings.value("autoconnectMixer", type=bool)
else:
autoconnectMixer = False
self.menu.addMenuEntry(
submenu = "menuSettings",
actionAsString = "actionAutoconnectMixer",
text = QtCore.QCoreApplication.translate("mainWindow", "Autoconnect Mixer Ports"),
connectedFunction = self.reactToAutoconnectMixerCheckbox,
tooltip = QtCore.QCoreApplication.translate("mainWindow", "Wether to autoconnect the mixer ports on program start. Not for NSM."),
checkable=True,
startChecked=autoconnectMixer,
)
self.menu.orderSubmenus(["menuFile", "menuSettings", "menuHelp"])
#self.menu.addMenuEntry("menuEdit", "actionNils", "Nils", lambda: print("Merle"))
#self.menu.connectMenuEntry("actionNils", lambda: print("Perle"))
self.ui.actionUndo.setVisible(False)
self.ui.actionRedo.setVisible(False)
self.ui.menuEdit.menuAction().setVisible(False)
self.start(additionalData={"autoconnectMixer":autoconnectMixer}) #Starts the engine, starts the eventLoop, 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.
#There is so much going on in the engine, we never reach a save status on load.
#Here is the crowbar-method.
self.nsmClient.announceSaveStatus(isClean = True)
def openSoundfontDialog(self):
"""Called by the menu and GUI directly to choose a soundfont file.
We forward the path to NSM which gives us a link in our session dir which we
give to the api.
The api will react with a callback and we can show our Channel Strips"""
if "lastOpenDirectory" in api.session.guiSharedDataToSave:
defaultDir = api.session.guiSharedDataToSave["lastOpenDirectory"]
else:
defaultDir = os.path.expanduser("~")
filePath, extension = QtWidgets.QFileDialog.getOpenFileName(self, "Open Soundfont", defaultDir, "Soundfont 2 (*.sf2);;All Files (*.*)")
if filePath: #not cancelled
linkedPath = self.nsmClient.importResource(filePath)
api.session.guiSharedDataToSave["lastOpenDirectory"] = os.path.dirname(filePath)
success = api.loadSoundfont(linkedPath)
if not success:
self.ui.label_soundfont_name.hide() #will be shown after loading an sf2
self.ui.checkBox_ignoreProgramChanges.hide() #will be shown after loading an sf2
self.ui.checkBox_playTestAfterSelectingProgram.hide()
for channelTreeItem in self.channels:
channelTreeItem.unloadData()
def reactToAutoconnectMixerCheckbox(self, state:bool):
assert api.isStandaloneMode()
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
settings.setValue("autoconnectMixer", state)
if state:
api.connectMixerToSystemPorts()
def saveCheckBox_playTestAfterSelectingProgram(self, state:int):
"""#one parameter: int state. 0 is unchecked, 1 is partially, 2 is checked"""
api.session.guiSharedDataToSave["playTestAfterSelectingProgram"] = state
def callback_ignoreProgramChangesChanged(self, state:int):
"""Redundant while the program runs. Useful for load / startup"""
self.ui.checkBox_ignoreProgramChanges.blockSignals(True)
self.ui.checkBox_ignoreProgramChanges.setChecked(state)
self.ui.checkBox_ignoreProgramChanges.blockSignals(False)
def updateChannelTreeItems(self, exportDict:dict):
for channelTreeItem in self.channels:
channelTreeItem.updateData(exportDict)
def callback_SoundFontLoaded(self, exportDict:dict):
"""Can be called multiple times. On load, first time file open, other file open"""
self.updateChannelTreeItems(exportDict)
self.setWindowTitle(exportDict["name"])
self.ui.label_soundfont_name.setText(exportDict["name"])
self.ui.label_soundfont_name.show()
self.ui.checkBox_ignoreProgramChanges.show()
self.ui.checkBox_playTestAfterSelectingProgram.show()
def callback_channelChanged(self, channel:int, exportDict:dict):
"""An outside signal (e.g. sequencer) changed a channels bank, program or both"""
self.channels[channel-1].callback_channelChanged(exportDict) #self.channels as python list is 0 based, channels are 1 based
def callback_channelActivity(self, channel:int):
self.channels[channel-1].activity() #self.channels as python list is 0 based, channels are 1 based
def dropEvent(self, event):
"""This function does not exist in the template.
It is easiest to edit it directly than to create another abstraction layer.
Having that function in the mainWindow will not make drops available for subwindows
like About or UserManual. """
for url in event.mimeData().urls():
filePath = url.toLocalFile()
#Decide here if you want only files, only directories, both etc.
if os.path.isfile(filePath) and filePath.lower().endswith(".sf2"):
linkedPath = self.nsmClient.importResource(filePath)
api.loadSoundfont(linkedPath)
def zoom(self, scaleFactor:float):
pass
def stretchXCoordinates(self, factor):
pass
class ChannelTreeItem(QtWidgets.QTreeWidgetItem):
"""Created once on program start. From then on only updated values
"""
def __init__(self, mainWindow, number):
super().__init__()
self.mainWindow = mainWindow
self.channelNumber = number
parameterList = [] #later in update, when a soundfont got actually loaded
super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type)
self.setText(self.mainWindow.rackTreeWidgetColumns.index("Channel"), str(self.channelNumber)) #fixed value
#Create icons for midi in status
on = QtGui.QPixmap(16,16)
oncolor = QtGui.QColor("cyan")
on.fill(oncolor)
self.onIcon = QtGui.QIcon(on)
off = QtGui.QPixmap(16,16)
offcolor = QtGui.QColor(50, 50, 50) #dark grey
off.fill(offcolor)
self.offIcon = QtGui.QIcon(off)
self.activeFlag = False #midi indicator
api.session.eventLoop.verySlowConnect(self._activityOff)
#ItemWidgets can only be created after the topLevelItem has been fully created and inserted.
#These will be created by self.initSubWidgets
self.comboBoxBank = None
self.comboBoxProgram = None
self.pushButtonPlayTest = None
self.patchlist = None #created by self.updateData
def initSubWidgets(self):
#setItemWidget(QTreeWidgetItem *item, int column, QWidget *widget)
self.comboBoxBank = QtWidgets.QComboBox()
self.comboBoxBank.setAutoFillBackground(True) #prevent collision with transparency and the cell below
self.comboBoxProgram = QtWidgets.QComboBox()
self.comboBoxProgram.setAutoFillBackground(True) #prevent collision with transparency and the cell below
self.pushButtonPlayTest = QtWidgets.QPushButton("")
self.pushButtonPlayTest.setAutoFillBackground(True) #prevent collision with transparency and the cell below
self.mainWindow.rackTreeWidget.setItemWidget(self, self.mainWindow.rackTreeWidgetColumns.index("Bank"), self.comboBoxBank)
self.mainWindow.rackTreeWidget.setItemWidget(self, self.mainWindow.rackTreeWidgetColumns.index("Program"), self.comboBoxProgram)
self.mainWindow.rackTreeWidget.setItemWidget(self, self.mainWindow.rackTreeWidgetColumns.index("Play Test"), self.pushButtonPlayTest)
self.comboBoxBank.currentIndexChanged.connect(self.reactToBankOrProgramChange) #blocked when changed via midi from the outside
self.comboBoxProgram.currentIndexChanged.connect(self.reactToBankOrProgramChange) #blocked when changed via midi from the outside
self.comboBoxProgram.activated.connect(self.reactToUserActivation) #Only for user interaction
self.pushButtonPlayTest.clicked.connect(lambda: api.playTestSignal(self.channelNumber))
self.setIcon(self.mainWindow.rackTreeWidgetColumns.index("Channel"), self.offIcon)
def _activityOff(self):
if self.activeFlag:
self.activeFlag = False
self.setIcon(self.mainWindow.rackTreeWidgetColumns.index("Channel"), self.offIcon)
def activity(self):
"""Show midi note ons as flashing light.
turning off is done by a 60ms timer for all channels."""
self.activeFlag = True
self.setIcon(self.mainWindow.rackTreeWidgetColumns.index("Channel"), self.onIcon)
#QtCore.QTimer().singleShot(50, self._activityOff) this doesn't scale too well
def unloadData(self):
"""When an sf2 fails to load we clean our content.
Activated through mainWindow.openSoundfontDialog"""
self.comboBoxProgram.clear()
self.comboBoxBank.clear()
self.activeFlag = False
self.setIcon(self.mainWindow.rackTreeWidgetColumns.index("Channel"), self.offIcon)
def _populateComboBox_Program(self, bank:int, program:int):
self.comboBoxProgram.clear()
assert 0 <= program < 128, program
logger.info(f"populate: {bank}:{program}")
if not bank in self.patchlist:
logger.warning(f"Bank {bank} requested but not in sf2. Falling back to bank 0")
bank = 0
nowProgram = self.comboBoxProgram.currentIndex() #if below fails at least we don't get an error. Worst case is the wrong label.
for prgnr, patchname in self.patchlist[bank].items():
#Not every program is in every bank. But we don' want a drop down list with gaps.
#So we save the program number as user data, which is only possile with addItem, not list based addItems
text = str(prgnr).zfill(3) + " " + patchname
self.comboBoxProgram.addItem(text, prgnr) #Icon, Text, UserData
if prgnr == program:
nowProgram = self.comboBoxProgram.count()-1
self.comboBoxProgram.setCurrentIndex(nowProgram) #both 0 based
def updateData(self, exportDict:dict):
"""
Fill in new data for a new soundfont.
The engine guarantees that the activePatches are actually in the patchlist.
This is important for a soundfont change to one that has fewer instruments.
The engine handles this reduction.
exportDict:
filePath: str
name: str
patchlist : dict{bankInt : {programInt:nameStr}}
activePatches : dict{channelIntFrom1: tuple(bank, prg, nameStr)}
"""
self.comboBoxBank.blockSignals(True)
self.comboBoxProgram.blockSignals(True)
self.exportDict = exportDict
bank, program, name = exportDict["activePatches"][self.channelNumber] #fluidsynth-channels from 1, gui channels from 1
self.patchlist = exportDict["patchlist"] #all patches in this soundfont. bank:{program:name}
#Banks
self.comboBoxBank.clear()
for banknr in self.patchlist:
self.comboBoxBank.addItem(str(banknr), banknr) #Icon, Text, UserData
if banknr == bank:
nowBank = self.comboBoxBank.count()-1
self.comboBoxBank.setCurrentIndex(nowBank)
#Programs
self._populateComboBox_Program(bank, program)
#Unblock Signals
self.comboBoxBank.blockSignals(False)
self.comboBoxProgram.blockSignals(False)
for index in range(len(self.mainWindow.rackTreeWidgetColumns)):
self.mainWindow.rackTreeWidget.resizeColumnToContents(index)
def reactToBankOrProgramChange(self, newIndex): #discard newIndex
bank = self.comboBoxBank.currentData()
program = self.comboBoxProgram.currentData()
if not bank is None and not program is None:
api.setPatch(self.channelNumber, bank, program) # triggers self.callback_channelChanged
def callback_channelChanged(self, exportDict):
"""Either triggered by a GUI action, routed through the api, or by incoming midi message"""
assert self.channelNumber == exportDict["channel"], (self.channelNumber, exportDict["channel"])
self.comboBoxBank.blockSignals(True)
self.comboBoxProgram.blockSignals(True)
comboIndex = self.comboBoxBank.findData(exportDict["bank"])
if comboIndex == -1: #qt for "not found"
#This was stupid. A bank change to a non-existing instrument is valid: raise ValueError("Bank not in patchlist")
pass
else:
self.comboBoxBank.setCurrentIndex(comboIndex) #both 0 based
self._populateComboBox_Program(exportDict["bank"], exportDict["program"])
self.comboBoxBank.blockSignals(False)
self.comboBoxProgram.blockSignals(False)
def reactToUserActivation(self, programNumberAsStr:str):
"""This is only emitted when the user activates the widget. However, it does not
differentiate between a change in selection or not.
This signal is emmited after the engine was notified of a changed program so we already
have the updated sound for a feedback.
"""
if self.mainWindow.ui.checkBox_playTestAfterSelectingProgram.isChecked():
api.playTestSignal(self.channelNumber)