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.
387 lines
19 KiB
387 lines
19 KiB
#! /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")
|
|
|
|
#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)
|
|
|