#! /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 ), Laborejo2 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 #Third party from PyQt5 import QtCore, QtGui, QtWidgets translate = QtCore.QCoreApplication.translate #Template from template.helper import pairwise #Our own files from .constantsAndConfigs import constantsAndConfigs from .designer.trackWidget import Ui_trackGroupWidget from .submenus import TickWidget, CombinedTickWidget from contextlib import contextmanager import engine.api as api class TrackWidget(QtWidgets.QGroupBox): #TODO: ideas: number of blocks, which CCs are set, review/change durationSettingsSignature, dynamicSettingsSignature def __init__(self, parentDataEditor, trackExportObject): super().__init__() self.parentDataEditor = parentDataEditor self.trackExportObject = trackExportObject self.ui = Ui_trackGroupWidget() self.ui.setupUi(self) self.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.trackExportObject = trackExportObject #updated on every self.updateData self.ui.visibleCheckbox.clicked.connect(self.visibleToggled) #only user changes, not through setChecked() self.ui.audibleCheckbox.clicked.connect(self.audibleToggled) #only user changes, not through setChecked() self.ui.doubleTrackCheckbox.clicked.connect(self.doubleTrackToggled) #only user changes, not through setChecked() #self.ui.upbeatSpinBox.editingFinished.connect(self.upbeatChanged) #only user changes, not through setText() etc. self.ui.upbeatSpinBox.valueChanged.connect(self.upbeatChanged) #also through the tickWidget self.ui.callTickWidget.clicked.connect(self.callClickWidgetForUpbeat) self.ui.nameLineEdit.editingFinished.connect(self.nameChanged) #only user changes, not through setText() etc. self.ui.deleteButton.clicked.connect(lambda: api.deleteTrack(self.trackExportObject["id"])) self.ui.midiChannelSpinBox.valueChanged.connect(self.dataChanged) self.ui.midiProgramSpinBox.valueChanged.connect(self.dataChanged) self.ui.midiBankMsbSpinBox.valueChanged.connect(self.dataChanged) self.ui.midiBankLsbSpinBox.valueChanged.connect(self.dataChanged) self.ui.midiTransposeSpinBox.valueChanged.connect(self.dataChanged) self.ui.instrumentName.editingFinished.connect(self.nameChanged) self.ui.shortInstrumentName.editingFinished.connect(self.nameChanged) #Create a menu with checkboxes to allow switching on and off of additional channels for the CC sub-track #However, we will not use normal checkable Menu actions since they close the menu after triggering. even blockSignals does not prevent closing self.ccChannels = {} ccChannelMenu = QtWidgets.QMenu() for i in range(1,17): #excluding 17 checkbox = QtWidgets.QCheckBox(ccChannelMenu) self.ccChannels[i] = checkbox checkbox.stateChanged.connect(self.dataChanged) widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setContentsMargins(0,0,0,0) #left, top, right, bottom in pixel widget.setLayout(layout) widgetAction = QtWidgets.QWidgetAction(ccChannelMenu) widgetAction.setDefaultWidget(widget) layout.addRow(str(i).zfill(2), checkbox) ccChannelMenu.addAction(widgetAction) #action = QtWidgets.QAction(str(i), ccChannelMenu) #action.setCheckable(True) #ccChannelMenu.addAction(action) self.ui.ccChannelsPushButton.setMenu(ccChannelMenu) #TODO: The callbacks below trigger quite often. If you move the mouse wheel in a spin box each increase/decrease sends ALL track settings to the backend which triggers a compelte redraw of the track. This should be measure on performance and improved. But even incremental updates should have an immediate effect on a running playback so "send changes when closing the track editor" is only a compromise". Incremental updates are not a a solution either because even those need a playback update. valueChanged does not have an editingFinished signal like text fields. #Set up the advanced properties. They should be hidden at the start #Tab 1: Durations self.ui.defaultOn.editingFinished.connect(self.dataChanged) #only user changes, not through setText() etc. self.ui.defaultOff.editingFinished.connect(self.dataChanged) self.ui.staccatoOn.editingFinished.connect(self.dataChanged) self.ui.staccatoOff.editingFinished.connect(self.dataChanged) self.ui.tenutoOn.editingFinished.connect(self.dataChanged) self.ui.tenutoOff.editingFinished.connect(self.dataChanged) self.ui.legatoOn.editingFinished.connect(self.dataChanged) self.ui.legatoOff.editingFinished.connect(self.dataChanged) #Tab 2: Dynamics self.ui.dynamics_ppppp.valueChanged.connect(self.dataChanged) self.ui.dynamics_pppp.valueChanged.connect(self.dataChanged) self.ui.dynamics_ppp.valueChanged.connect(self.dataChanged) self.ui.dynamics_pp.valueChanged.connect(self.dataChanged) self.ui.dynamics_p.valueChanged.connect(self.dataChanged) self.ui.dynamics_mp.valueChanged.connect(self.dataChanged) self.ui.dynamics_mf.valueChanged.connect(self.dataChanged) self.ui.dynamics_f.valueChanged.connect(self.dataChanged) self.ui.dynamics_ff.valueChanged.connect(self.dataChanged) self.ui.dynamics_fff.valueChanged.connect(self.dataChanged) self.ui.dynamics_ffff.valueChanged.connect(self.dataChanged) self.ui.dynamics_custom.valueChanged.connect(self.dataChanged) self.ui.dynamics_tacet.valueChanged.connect(self.dataChanged) self.ui.dynamics_fp.valueChanged.connect(self.dataChanged) self.ui.dynamics_sp.valueChanged.connect(self.dataChanged) self.ui.dynamics_spp.valueChanged.connect(self.dataChanged) self.ui.dynamics_sfz.valueChanged.connect(self.dataChanged) self.ui.dynamics_sf.valueChanged.connect(self.dataChanged) self.ui.dynamics_sff.valueChanged.connect(self.dataChanged) self.ui.buttonResetDurations.clicked.connect(lambda: api.resetDuationSettingsSignature(self.trackExportObject["id"])) self.ui.buttonResetDynamics.clicked.connect(lambda: api.resetDynamicSettingsSignature(self.trackExportObject["id"])) #Connect the hide checkbox self.ui.advanced.toggled.connect(self.advancedToggled) self.ui.advanced.setChecked(False) def visibleToggled(self, signal): assert signal == bool(self.ui.visibleCheckbox.checkState()) if signal: api.unhideTrack(self.trackExportObject["id"]) else: api.hideTrack(self.trackExportObject["id"]) def audibleToggled(self, signal): assert signal == bool(self.ui.audibleCheckbox.checkState()) api.trackAudible(self.trackExportObject["id"], signal) def doubleTrackToggled(self, signal): api.setDoubleTrack(self.trackExportObject["id"], signal) def advancedToggled(self, bool): assert bool == self.ui.advanced.isChecked() if bool: self.ui.advancedContent.show() else: self.ui.advancedContent.hide() @contextmanager def blockUiSignals(self): """prevent loops by blocking all signals that react on changed states. Revoked at the end of the calling function. Strictly this is not needed for signals like .clicked() (compared to .toggled()) but it is a good last line of defense to prevent future bugs. Those signal-loops are hard to track. ADD ALL NEW SUB WIDGETS HERE!!!!! THIS HAPPENED TWICED ALREADY AND WAS A NIGHTMARE TO DEBUG! SIMPLY USING self.blockSignals(True) HAS NO EFFECT BECAUSE THOSE ARE IN .ui. self.setUpdatesEnabled(False) doesn't work as well. self.children() does not return the right children either, self.ui() is a python object, not a qt widget.""" for widget in self.ui.__dict__.values(): widget.blockSignals(True) for checkbox in self.ccChannels.values(): checkbox.blockSignals(True) yield for widget in self.ui.__dict__.values(): widget.blockSignals(False) for checkbox in self.ccChannels.values(): checkbox.blockSignals(False) def callClickWidgetForUpbeat(self): dialog = TickWidget(self.parentDataEditor, initValue = self.ui.upbeatSpinBox.value()) self.ui.upbeatSpinBox.setValue(dialog.ui.ticks.value()) def nameChanged(self): """When enter is pressed or focus is lost""" t = self.ui.nameLineEdit.text() name = self.ui.instrumentName.text() short = self.ui.shortInstrumentName.text() api.setTrackName(self.trackExportObject["id"], nameString = t, initialInstrumentName = name, initialShortInstrumentName = short) def upbeatChanged(self): """When enter is pressed or focus is lost""" v = self.ui.upbeatSpinBox.value() api.setTrackUpbeat(self.trackExportObject["id"], v) def dataChanged(self): """Our data changed. Send to Engine""" with self.blockUiSignals(): dictionary = {} dictionary["initialMidiChannel"] = self.ui.midiChannelSpinBox.value()-1 dictionary["initialMidiProgram"] = self.ui.midiProgramSpinBox.value() dictionary["initialMidiBankMsb"] = self.ui.midiBankMsbSpinBox.value() dictionary["initialMidiBankLsb"] = self.ui.midiBankLsbSpinBox.value() dictionary["ccChannels"] = tuple(chanNum-1 for chanNum, checkbox in self.ccChannels.items() if checkbox.checkState()) #can be unsorted dictionary["midiTranspose"] = self.ui.midiTransposeSpinBox.value() dictionary["duration.defaultOn"] = self.ui.defaultOn.text() dictionary["duration.defaultOff"] = self.ui.defaultOff.text() dictionary["duration.staccatoOn"] = self.ui.staccatoOn.text() dictionary["duration.staccatoOff"] = self.ui.staccatoOff.text() dictionary["duration.tenutoOn"] = self.ui.tenutoOn.text() dictionary["duration.tenutoOff"] = self.ui.tenutoOff.text() dictionary["duration.legatoOn"] = self.ui.legatoOn.text() dictionary["duration.legatoOff"] = self.ui.legatoOff.text() dictionary["dynamics.ppppp"] = self.ui.dynamics_ppppp.value() dictionary["dynamics.pppp"] = self.ui.dynamics_pppp.value() dictionary["dynamics.ppp"] = self.ui.dynamics_ppp.value() dictionary["dynamics.pp"] = self.ui.dynamics_pp.value() dictionary["dynamics.p"] = self.ui.dynamics_p.value() dictionary["dynamics.mp"] = self.ui.dynamics_mp.value() dictionary["dynamics.mf"] = self.ui.dynamics_mf.value() dictionary["dynamics.f"] = self.ui.dynamics_f.value() dictionary["dynamics.ff"] = self.ui.dynamics_ff.value() dictionary["dynamics.fff"] = self.ui.dynamics_fff.value() dictionary["dynamics.ffff"] = self.ui.dynamics_ffff.value() dictionary["dynamics.custom"] = self.ui.dynamics_custom.value() dictionary["dynamics.tacet"] = self.ui.dynamics_tacet.value() dictionary["dynamics.fp"] = self.ui.dynamics_fp.value() dictionary["dynamics.sp"] = self.ui.dynamics_sp.value() dictionary["dynamics.spp"] = self.ui.dynamics_spp.value() dictionary["dynamics.sfz"] = self.ui.dynamics_sfz.value() dictionary["dynamics.sf"] = self.ui.dynamics_sf.value() dictionary["dynamics.sff"] = self.ui.dynamics_sff.value() if not self.trackExportObject == dictionary: #checks for keys and values api.setTrackSettings(self.trackExportObject["id"], dictionary) #else: no change def updateData(self, trackExportObject): """Receives api updates. Change GUI fields accordingly""" self.trackExportObject = trackExportObject with self.blockUiSignals(): self.trackExportObject = trackExportObject self.ui.upbeatSpinBox.setValue(trackExportObject["upbeatInTicks"]) self.ui.nameLineEdit.setText(trackExportObject["name"]) self.ui.midiChannelSpinBox.setValue(trackExportObject["initialMidiChannel"]+1) self.ui.midiProgramSpinBox.setValue(trackExportObject["initialMidiProgram"]) self.ui.midiBankMsbSpinBox.setValue(trackExportObject["initialMidiBankMsb"]) self.ui.midiBankLsbSpinBox.setValue(trackExportObject["initialMidiBankLsb"]) self.ui.midiTransposeSpinBox.setValue(trackExportObject["midiTranspose"]) self.ui.instrumentName.setText(trackExportObject["initialInstrumentName"]) self.ui.shortInstrumentName.setText(trackExportObject["initialShortInstrumentName"]) self.ui.defaultOn.setText(trackExportObject["duration.defaultOn"]) self.ui.defaultOff.setText(trackExportObject["duration.defaultOff"]) self.ui.staccatoOn.setText(trackExportObject["duration.staccatoOn"]) self.ui.staccatoOff.setText(trackExportObject["duration.staccatoOff"]) self.ui.tenutoOn.setText(trackExportObject["duration.tenutoOn"]) self.ui.tenutoOff.setText(trackExportObject["duration.tenutoOff"]) self.ui.legatoOn.setText(trackExportObject["duration.legatoOn"]) self.ui.legatoOff.setText(trackExportObject["duration.legatoOff"]) self.ui.dynamics_ppppp.setValue(trackExportObject["dynamics.ppppp"]) self.ui.dynamics_pppp.setValue(trackExportObject["dynamics.pppp"]) self.ui.dynamics_ppp.setValue(trackExportObject["dynamics.ppp"]) self.ui.dynamics_pp.setValue(trackExportObject["dynamics.pp"]) self.ui.dynamics_p.setValue(trackExportObject["dynamics.p"]) self.ui.dynamics_mp.setValue(trackExportObject["dynamics.mp"]) self.ui.dynamics_mf.setValue(trackExportObject["dynamics.mf"]) self.ui.dynamics_f.setValue(trackExportObject["dynamics.f"]) self.ui.dynamics_ff.setValue(trackExportObject["dynamics.ff"]) self.ui.dynamics_fff.setValue(trackExportObject["dynamics.fff"]) self.ui.dynamics_ffff.setValue(trackExportObject["dynamics.ffff"]) self.ui.dynamics_custom.setValue(trackExportObject["dynamics.custom"]) self.ui.dynamics_tacet.setValue(trackExportObject["dynamics.tacet"]) self.ui.dynamics_fp.setValue(trackExportObject["dynamics.fp"]) self.ui.dynamics_sp.setValue(trackExportObject["dynamics.sp"]) self.ui.dynamics_spp.setValue(trackExportObject["dynamics.spp"]) self.ui.dynamics_sfz.setValue(trackExportObject["dynamics.sfz"]) self.ui.dynamics_sf.setValue(trackExportObject["dynamics.sf"]) self.ui.dynamics_sff.setValue(trackExportObject["dynamics.sff"]) if trackExportObject["initialMidiProgram"] >= 0: self.ui.midiBankMsbSpinBox.setEnabled(True) self.ui.midiBankLsbSpinBox.setEnabled(True) else: self.ui.midiBankMsbSpinBox.setEnabled(False) self.ui.midiBankLsbSpinBox.setEnabled(False) #hidden position can be 0 as well. So don't bool, check for None explicitely. if trackExportObject["hiddenPosition"] is None: #visible self.ui.visibleCheckbox.setChecked(True) self.ui.deleteButton.setEnabled(True) else: self.ui.visibleCheckbox.setChecked(False) self.ui.deleteButton.setEnabled(False) #You cannot delete hidden tracks. see api.deleteTrack self.ui.doubleTrackCheckbox.setChecked(trackExportObject["double"]) self.ui.audibleCheckbox.setChecked(trackExportObject["audible"]) for i in range(0,16): #without 16 #Engine from 0 to 15, GUI from 1 to 16. self.ccChannels[i+1].setChecked(i in trackExportObject["ccChannels"]) #a checkbox widget. class TrackEditor(QtWidgets.QWidget): """Created by ControlMainWindow. One permanent instance""" def __init__(self, mainWindow): super().__init__() self.mainWindow = mainWindow self.layout = QtWidgets.QVBoxLayout() self.layout.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) self.setLayout(self.layout) api.callbacks.tracksChangedIncludingHidden.append(self.updateTrackWidgets) self.tracks = {} #id:trackWidget. As any dict, not in order #Upbeat Tick Widget and Action-Button allUpbeats = QtWidgets.QWidget() allUpbeatsLayout = QtWidgets.QHBoxLayout() allUpbeatsLayout.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) allUpbeats.setLayout(allUpbeatsLayout) self.layout.addWidget(allUpbeats) self.allUpbeatsCombinedTickWidget = CombinedTickWidget() allUpbeatsPushButton = QtWidgets.QPushButton(translate("trackEditorPythonFile", "set all upbeats")) allUpbeatsPushButton.clicked.connect(self.setAllUpbeats) #gets the value itself allUpbeatsLayout.addWidget(self.allUpbeatsCombinedTickWidget) allUpbeatsLayout.addWidget(allUpbeatsPushButton) #Reset all Advanced Views foldAllAdvanvced = QtWidgets.QPushButton(translate("trackEditorPythonFile", "fold all advanced")) foldAllAdvanvced.clicked.connect(self.foldAllAdvanvced) allUpbeatsLayout.addWidget(foldAllAdvanvced) unfoldAllAdvanvced = QtWidgets.QPushButton(translate("trackEditorPythonFile", "unfold all advanced")) unfoldAllAdvanvced.clicked.connect(self.unfoldAllAdvanced) allUpbeatsLayout.addWidget(unfoldAllAdvanvced) def updateTrackWidgets(self, listOfStaticTrackRepresentations): """React to the backend adding, deleting, hiding or moving tracks. A track widget persists until the track gets deleted. No re-creation on every change. """ leftOver = list(self.tracks.keys()) visibleTracks = [] trackWidgetsInOrder = [] nameLineEditsInOrder = [] upbeatSpinBoxsInOrder = [] for trackExportObject in listOfStaticTrackRepresentations: #First are all normal, visible tracks. #After that the tracks have a property "hiddenPosition" for hidden tracks that need individual sorting. if not trackExportObject["id"] in self.tracks: #A brand new track widget = TrackWidget(self, trackExportObject) self.tracks[trackExportObject["id"]] = widget self.layout.insertWidget(-1, widget) widget.setTitle("id: {}".format(trackExportObject["id"])) else: leftOver.remove(trackExportObject["id"]) self.tracks[trackExportObject["id"]].ui.deleteButton.setEnabled(True) self.tracks[trackExportObject["id"]].blockSignals(True) self.tracks[trackExportObject["id"]].updateData(trackExportObject) #This has a sideeffect of updating widgets, which will change backend data and trigger a recursive updateTrackWidgets. We need to block the signals. self.tracks has the track widgets. self.tracks[trackExportObject["id"]].blockSignals(False) nameLineEditsInOrder.append(self.tracks[trackExportObject["id"]].ui.nameLineEdit) trackWidgetsInOrder.append(self.tracks[trackExportObject["id"]]) if trackExportObject["hiddenPosition"] is None: visibleTracks.append(trackExportObject) for trId in leftOver: #track still exist here but not in the backend. Delete. w = self.tracks[trId] del self.tracks[trId] w.setParent(None) w.close() #Sort tracks as they are in the backend/score-view for widget in trackWidgetsInOrder: #visibleTracks is sorted and does not have empty tracks. self.layout.removeWidget(widget) for widget in trackWidgetsInOrder: #Re-Insert all, this time in order. self.layout.insertWidget(-1, widget) #Create tab order. Those work in pairs, linked list. a before b. And multiple of those pairs need to be in order itself. if len(nameLineEditsInOrder) > 1: for pair in pairwise(nameLineEditsInOrder): self.setTabOrder(*pair) #takes exactly two arguments if len(visibleTracks) == 1: w = self.tracks[visibleTracks[0]["id"]] w.ui.deleteButton.setEnabled(False) #it is not possible to delete the only visible track. The backend will prevent it but we don't even offer the choice here. def setAllUpbeats(self): for trackWidget in self.tracks.values(): trackWidget.ui.upbeatSpinBox.setValue(self.allUpbeatsCombinedTickWidget.value()) def foldAllAdvanvced(self): for trackWidget in self.tracks.values(): trackWidget.ui.advanced.setChecked(False) def unfoldAllAdvanced(self): for trackWidget in self.tracks.values(): trackWidget.ui.advanced.setChecked(True) def keyPressEvent(self, event): """Escape closes the track editor""" k = event.key() #49=1, 50=2 etc. if k == QtCore.Qt.Key_Escape: self.mainWindow.ui.actionData_Editor.setChecked(False) self.mainWindow.toggleMainView() super().keyPressEvent(event) """ api.callbacks.updateBlockTrack.append(lambda trId, blocksExportData: self.tracks[trId].blockScene.regenerateBlocks(blocksExportData)) class TrackBlockScene(QtWidgets.QGraphicsScene): def __init__(self, parentWidget): super().__init__() self.blocks = [] self.parentWidget = parentWidget def mousePressEvent(self, event): button = event.button() if button == 1: #left self.setEnabled(False) super().mousePressEvent(event) def mouseReleaseEvent(self, event): button = event.button() if button == 1: #left self.setEnabled(True) super().mouseReleaseEvent(event) def regenerateBlocks(self, blocksExportData): self.clear() self.blocks = [] dur = 0 for backendBlock in blocksExportData: b = BlockGraphicsItem(self, backendBlock) self.blocks.append(b) self.addItem(b) b.setPos(backendBlock["tickindex"] / constantsAndConfigs.blocksTicksToPixelRatio, 0) dur += backendBlock["completeDuration"] self.parentWidget.ui.blocks.setFixedSize(dur / constantsAndConfigs.blocksTicksToPixelRatio, 30) class BlockGraphicsItem(QtWidgets.QGraphicsRectItem): def __init__(self, parentTrackWidget, blockExportObject): width = blockExportObject["completeDuration"] / constantsAndConfigs.blocksTicksToPixelRatio super().__init__(0,0,width, 30) #x, y, w, h self.setBrush(self.stringToColor(blockExportObject["name"])) #self.setFlags(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable) #self.setCursor(QtCore.Qt.SizeAllCursor) def stringToColor(self, st): if st: c = md5(st.encode()).hexdigest() return QtGui.QColor(int(c[0:9],16) % 255, int(c[10:19],16) % 255, int(c[20:29],16)% 255, 255) else: return QtGui.QColor(255,255,255,255) #Return White """