#! /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 . """ 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 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) #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() #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 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)