From b9765d3fbd24b9dece69b1ce173e22ea488f5911 Mon Sep 17 00:00:00 2001 From: Nils Date: Sun, 31 Jul 2022 18:18:29 +0200 Subject: [PATCH] Add SOLO functionality alongside the existing audible/mute layer. Control via shortcuts, track editor, or track list widget --- CHANGELOG | 2 ++ engine/api.py | 47 ++++++++++++++++++++++++++++++++--- engine/cursor.py | 1 + engine/main.py | 41 +++++++++++++++++++++--------- engine/track.py | 30 +++++++++++++++++++--- qtgui/designer/mainwindow.py | 11 +++++++- qtgui/designer/mainwindow.ui | 15 +++++++++++ qtgui/designer/trackWidget.py | 8 ++++-- qtgui/designer/trackWidget.ui | 9 ++++++- qtgui/menu.py | 3 +++ qtgui/trackEditor.py | 15 +++++++++-- qtgui/tracklistwidget.py | 19 +++++++++++--- 12 files changed, 173 insertions(+), 28 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a83fab4..6b18b48 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,11 +6,13 @@ External contributors notice at the end of the line: (LastName, FirstName / nick ## 2022-10-15 2.2.0 +Add SOLO functionality alongside the existing audible/mute layer. Control via shortcuts, track editor, or track list widget Add more time signatures to the quick-insert dialog: 8/4, three variants of 7/8, two variants of 5/4 and more. Also reorder and better labels. Fix splitting of notes that were created by a previous split. Block Mode: Fix invisible block labels and graphics when dragging blocks (adopting to unannounced Qt regressions once again) Barlines more visible Undo for initial metrical instruction and key sig (track editor) +Prevent block operations to jump to the cursor position in the GUI. Less jumpy. Various small fixes, like typos in variable names and wrong string quotes. Small things can crash as well. Lilypond: Add transposition of the whole score to properties and metadata dialog diff --git a/engine/api.py b/engine/api.py index ff0bc4c..1cb9b53 100644 --- a/engine/api.py +++ b/engine/api.py @@ -626,10 +626,12 @@ def insertTrack(atIndex, trackObject): moveFunction() deleteTrack(newTrackId) session.history.register(registeredUndoFunction, descriptionString = "insert track") + session.data.calculateAudibleSoloForCbox() callbacks._tracksChanged() callbacks._updateTrack(newTrackId) callbacks._setCursor() +#search tags: newTrack addTrack def newEmptyTrack(): """Append an empty track and switch to the new track""" newIndex = len(session.data.tracks) @@ -660,6 +662,7 @@ def deleteTrack(trId): callbacks._setCursor() if trackObject is session.data.currentMetronomeTrack: useCurrentTrackAsMetronome() #we already have a new current one + session.data.calculateAudibleSoloForCbox() def deleteCurrentTrack(): deleteTrack(id(session.data.currentTrack())) @@ -689,13 +692,51 @@ def unhideTrack(trId): def trackAudible(trId, state:bool): """ + Aka. mute, but we don't call it like this because: Send midi notes or not. CCs and instrument changes are unaffected. Not managed by undo/redo. - Does not need updateTrack. There is no new midi data to generate. cbox handles mute on its own""" - trackObject = session.data.trackById(trId) - trackObject.sequencerInterface.enable(state) + Does not need updateTrack. There is no new midi data to generate. cbox handles mute on its own + + Audible will shut off any output, no matter if solo or not. + Solo will determine which of the audible tracks are played. + Like in any DAW. Inverted Solo logic etc. + """ + session.data.trackById(trId).audible = state + session.data.calculateAudibleSoloForCbox() callbacks._tracksChanged() #even if there is no change the GUI needs to be notified to redraw its checkboxes that may have been enabled GUI-side. + +def trackSolo(trId, state:bool): + """ + Another layer like audible tracks. + This is the classic solo/mute duality. + + Audible will shut off any output, no matter if solo or not. + Solo will determine which of the audible tracks are played. + Like in any DAW. Inverted Solo logic etc. + + Not managed by undo/redo. + Does not need updateTrack. There is no new midi data to generate. cbox handles mute on its own + """ + session.data.trackById(trId).solo = state + session.data.calculateAudibleSoloForCbox() + callbacks._setCursor() #the cursor includes solo export for the current track. + callbacks._tracksChanged() #even if there is no change the GUI needs to be notified to redraw its checkboxes that may have been enabled GUI-side. + + +def toggleCurrentTrackSolo(): + """trackSolo, but for the cursor. And toggle""" + track = session.data.currentTrack() + trackSolo(id(track), not track.solo) + +def resetAllSolo(): + for track in session.data.tracks + list(session.data.hiddenTracks.keys()): + track.solo = False + session.data.calculateAudibleSoloForCbox() + callbacks._setCursor() #the cursor includes solo export for the current track. + callbacks._tracksChanged() #even if there is no change the GUI needs to be notified to redraw its checkboxes that may have been enabled GUI-side. + + def listOfTrackIds(): return session.data.listOfTrackIds() diff --git a/engine/cursor.py b/engine/cursor.py index 1885820..4cf76fe 100644 --- a/engine/cursor.py +++ b/engine/cursor.py @@ -128,6 +128,7 @@ class Cursor: "trackName" : trackState.track.name, "cboxMidiOutUuid" : trackState.track.sequencerInterface.cboxMidiOutUuid, #used for midi throught. Step midi shall produce sound through the current track. "midiChannel" : trackState.midiChannel(), #zero based + "solo" : trackState.track.solo, "trackId" : id(trackState.track), "position" : trackState.position(), "tickindex" : trackState.tickindex, diff --git a/engine/main.py b/engine/main.py index 9f21558..4db919c 100644 --- a/engine/main.py +++ b/engine/main.py @@ -60,6 +60,7 @@ class Data(template.engine.sequencer.Score): self.copyObjectsBuffer = [] #for copy and paste. obviously empty after load file. Also not saved. self.cachedTrackDurations = {} #updated after every track export #track.asMetronomeData is a generated value from staticExport. Not available yet. needs to be done in api.startEngine #self.metronome.generate(data=self.currentMetronomeTrack.asMetronomeData, label=self.currentMetronomeTrack.name) + self.calculateAudibleSoloForCbox() def duration(self): @@ -1050,6 +1051,34 @@ class Data(template.engine.sequencer.Score): dictOfTrackIdsWithListOfBlockIds[trId] = listOfBlockIds return dictOfTrackIdsWithListOfBlockIds + def getMidiInputNameAndUuid(self): + """ + Return name and cboxMidiPortUid. + name is Client:Port JACK format + + Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and + sequencers. Used by the quick connect midi input widget. + If double None as return the widget in the GUI might hide and deactivate itself.""" + + return None, None + + + def calculateAudibleSoloForCbox(self): + """Call this after setting any track audible or solo. + The function calculates what will actually get played back. + This is only for Notes. CCs will be always get played back. + """ + + anySolo = any(track.solo for track in self.tracks + list(self.hiddenTracks.keys())) #hidden tracks are audible. + + for track in self.tracks + list(self.hiddenTracks.keys()): + if anySolo: + state = track.solo and track.audible + else: + state = track.audible + track.sequencerInterface.enable(state) + + #Save / Load / Export def lilypond(self): """Entry point for converting the score into lilypond. Called by session.saveAsLilypond(), returns a string, which is the file content. @@ -1064,19 +1093,7 @@ class Data(template.engine.sequencer.Score): #From Template has direct access to the score metadata and WILL destructively modify it's own parameters, like the lilypond template entry. return fromTemplate(session = self.parentSession, data = data, meta = self.metaData, tempoStaff = tempoStaff) - def getMidiInputNameAndUuid(self): - """ - Return name and cboxMidiPortUid. - name is Client:Port JACK format - Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and - sequencers. Used by the quick connect midi input widget. - If double None as return the widget in the GUI might hide and deactivate itself.""" - - return None, None - - - #Save / Load / Export def serialize(self)->dict: dictionary = super().serialize() dictionary["class"] = self.__class__.__name__ diff --git a/engine/track.py b/engine/track.py index 1dfc8f9..26a9137 100644 --- a/engine/track.py +++ b/engine/track.py @@ -402,10 +402,16 @@ class Track(object): self.asMetronomeData = None #This track as metronome version. Is always up to date through export. + #Version 2.2.0 + self.audible = True #requires main.calculatedMuteSoloForCbox after changing and after load + self.solo = False #requires main.calculatedMuteSoloForCbox after changing and after load + self._processAfterInit() def _processAfterInit(self): - """Call this after either init or instanceFromSerializedData""" + """Call this after either init or instanceFromSerializedData + Mute and Solo evaluation is in main. + It's not included here because it need an overview of all tracks.""" self.state = TrackState(self) Track.allTracks[id(self)] = self #remember track as weakref #weakref_finalize(self, print, "deleted track "+str(id(self))) @@ -927,7 +933,7 @@ class Track(object): def serialize(self)->dict: return { - "sequencerInterface" : self.sequencerInterface.serialize(), + "sequencerInterface" : self.sequencerInterface.serialize(), #this saves the actual cbox.enabled value. But that is harmless, first because the state is actually valid, second because we recalculate mute/solo after load anyway. "blocks" : [block.serialize() for block in self.blocks], "ccGraphTracks" : {ccNumber:graphTrackCC.serialize() for ccNumber, graphTrackCC in self.ccGraphTracks.items()}, "durationSettingsSignature" : self.durationSettingsSignature.serialize(), @@ -946,6 +952,10 @@ class Track(object): "initialInstrumentName" : self.initialInstrumentName, "initialShortInstrumentName" : self.initialShortInstrumentName, "upbeatInTicks" : self.upbeatInTicks, + + #2.2.0 + "audible" : self.audible, #bool + "solo" : self.solo, #bool } @@ -954,7 +964,7 @@ class Track(object): def instanceFromSerializedData(cls, parentData, serializedData): self = cls.__new__(cls) self.parentData = parentData - self.sequencerInterface = template.engine.sequencer.SequencerInterface.instanceFromSerializedData(self, serializedData["sequencerInterface"]) + self.sequencerInterface = template.engine.sequencer.SequencerInterface.instanceFromSerializedData(self, serializedData["sequencerInterface"]) #this loads the actual cbox.enabled value. But that is harmless, first because the state is actually valid, second because we recalculate mute/solo after load anyway. self.upbeatInTicks = int(serializedData["upbeatInTicks"]) self.blocks = [Block.instanceFromSerializedData(block, parentObject = self) for block in serializedData["blocks"]] @@ -987,6 +997,17 @@ class Track(object): else: self.initialMetricalInstruction = MetricalInstruction(tuple(), isMetrical = False) + #2.2.0 + if "audible" in serializedData: + self.audible = serializedData["audible"] #bool + else: + self.audible = True + + if "solo" in serializedData: + self.solo = serializedData["solo"] #bool + else: + self.solo = False + self._processAfterInit() return self @@ -1018,7 +1039,8 @@ class Track(object): "index" : self.state.index(), "upbeatInTicks": int(self.upbeatInTicks), "double" : self.double, - "audible" : self.sequencerInterface.enabled, + "audible" : self.audible, + "solo" : self.solo, "initialClefKeyword" : self.initialClefKeyword, "initialMidiChannel" : self.initialMidiChannel, "initialMidiProgram" : self.initialMidiProgram, diff --git a/qtgui/designer/mainwindow.py b/qtgui/designer/mainwindow.py index daa0499..cf59bbb 100644 --- a/qtgui/designer/mainwindow.py +++ b/qtgui/designer/mainwindow.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'mainwindow.ui' # -# Created by: PyQt5 UI code generator 5.15.6 +# Created by: PyQt5 UI code generator 5.15.7 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -693,6 +693,10 @@ class Ui_MainWindow(object): self.actionCustom_Metrical_Instruction.setObjectName("actionCustom_Metrical_Instruction") self.actionDuplicateItem_more = QtWidgets.QAction(MainWindow) self.actionDuplicateItem_more.setObjectName("actionDuplicateItem_more") + self.actionToggle_Track_Solo_Playback = QtWidgets.QAction(MainWindow) + self.actionToggle_Track_Solo_Playback.setObjectName("actionToggle_Track_Solo_Playback") + self.actionReset_all_Solo = QtWidgets.QAction(MainWindow) + self.actionReset_all_Solo.setObjectName("actionReset_all_Solo") self.menuObjects.addAction(self.actionMetrical_Instruction) self.menuObjects.addAction(self.actionCustom_Metrical_Instruction) self.menuObjects.addAction(self.actionClef) @@ -778,6 +782,8 @@ class Ui_MainWindow(object): self.menuTracks.addAction(self.actionAdd_Track) self.menuTracks.addAction(self.actionDelete_Current_Track) self.menuTracks.addAction(self.actionUse_Current_Track_as_Metronome) + self.menuTracks.addAction(self.actionToggle_Track_Solo_Playback) + self.menuTracks.addAction(self.actionReset_all_Solo) self.menuTracks.addSeparator() self.menuTracks.addAction(self.actionBlock_Properties) self.menuTracks.addAction(self.actionSplit_Current_Block) @@ -1059,3 +1065,6 @@ class Ui_MainWindow(object): self.actionCustom_Metrical_Instruction.setShortcut(_translate("MainWindow", "Alt+M")) self.actionDuplicateItem_more.setText(_translate("MainWindow", "Duplicate more")) self.actionDuplicateItem_more.setShortcut(_translate("MainWindow", "Ctrl+Shift+D")) + self.actionToggle_Track_Solo_Playback.setText(_translate("MainWindow", "Toggle Track Solo Playback")) + self.actionToggle_Track_Solo_Playback.setShortcut(_translate("MainWindow", "Z")) + self.actionReset_all_Solo.setText(_translate("MainWindow", "Reset Solo for all Tracks")) diff --git a/qtgui/designer/mainwindow.ui b/qtgui/designer/mainwindow.ui index def747e..e0012dd 100644 --- a/qtgui/designer/mainwindow.ui +++ b/qtgui/designer/mainwindow.ui @@ -161,6 +161,8 @@ + + @@ -1861,6 +1863,19 @@ Ctrl+Shift+D + + + Toggle Track Solo Playback + + + Z + + + + + Reset Solo for all Tracks + + diff --git a/qtgui/designer/trackWidget.py b/qtgui/designer/trackWidget.py index e014761..3fd1d66 100644 --- a/qtgui/designer/trackWidget.py +++ b/qtgui/designer/trackWidget.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'trackWidget.ui' # -# Created by: PyQt5 UI code generator 5.15.6 +# Created by: PyQt5 UI code generator 5.15.7 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -14,7 +14,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets class Ui_trackGroupWidget(object): def setupUi(self, trackGroupWidget): trackGroupWidget.setObjectName("trackGroupWidget") - trackGroupWidget.resize(1419, 548) + trackGroupWidget.resize(1473, 548) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -79,6 +79,9 @@ class Ui_trackGroupWidget(object): self.audibleCheckbox.setChecked(True) self.audibleCheckbox.setObjectName("audibleCheckbox") self.horizontalLayout.addWidget(self.audibleCheckbox) + self.soloCheckbox = QtWidgets.QCheckBox(self.track) + self.soloCheckbox.setObjectName("soloCheckbox") + self.horizontalLayout.addWidget(self.soloCheckbox) self.visibleCheckbox = QtWidgets.QCheckBox(self.track) self.visibleCheckbox.setChecked(True) self.visibleCheckbox.setObjectName("visibleCheckbox") @@ -467,6 +470,7 @@ class Ui_trackGroupWidget(object): self.callTickWidget.setText(_translate("trackGroupWidget", "𝅘𝅥𝅮 ")) self.doubleTrackCheckbox.setText(_translate("trackGroupWidget", "Double Track")) self.audibleCheckbox.setText(_translate("trackGroupWidget", "Audible")) + self.soloCheckbox.setText(_translate("trackGroupWidget", "Solo")) self.visibleCheckbox.setText(_translate("trackGroupWidget", "Visible")) self.midiChannelSpinBox.setPrefix(_translate("trackGroupWidget", "Channel ")) self.ccChannelsPushButton.setText(_translate("trackGroupWidget", "CC Channels")) diff --git a/qtgui/designer/trackWidget.ui b/qtgui/designer/trackWidget.ui index e0613b4..8999cce 100644 --- a/qtgui/designer/trackWidget.ui +++ b/qtgui/designer/trackWidget.ui @@ -6,7 +6,7 @@ 0 0 - 1419 + 1473 548 @@ -179,6 +179,13 @@ + + + + Solo + + + diff --git a/qtgui/menu.py b/qtgui/menu.py index ba12c80..9ce8037 100644 --- a/qtgui/menu.py +++ b/qtgui/menu.py @@ -198,6 +198,9 @@ class MenuActionDatabase(object): self.mainWindow.ui.actionAdd_Track : api.newEmptyTrack, self.mainWindow.ui.actionDelete_Current_Track : api.deleteCurrentTrack, self.mainWindow.ui.actionUse_Current_Track_as_Metronome : api.useCurrentTrackAsMetronome, + self.mainWindow.ui.actionToggle_Track_Solo_Playback : api.toggleCurrentTrackSolo, + self.mainWindow.ui.actionReset_all_Solo : api.resetAllSolo, + self.mainWindow.ui.actionMidi_In_is_Active : self.toggleMidiInIsActive, self.mainWindow.ui.actionZoom_In_Score_View : self.mainWindow.zoomIn, self.mainWindow.ui.actionZoom_Out_Score_View : self.mainWindow.zoomOut, diff --git a/qtgui/trackEditor.py b/qtgui/trackEditor.py index 71da5b3..4b1c9cc 100644 --- a/qtgui/trackEditor.py +++ b/qtgui/trackEditor.py @@ -42,7 +42,6 @@ import engine.api as api LIST_OF_CLEF_KEYWORDS = api.getPossibleClefKeywords() #TODO: translate? But keep the keyword as index. setData class TrackWidget(QtWidgets.QGroupBox): - #TODO: ideas: number of blocks, list of block names which CCs are set, review/change durationSettingsSignature, dynamicSettingsSignature def __init__(self, parentDataEditor, trackExportObject): super().__init__() self.parentDataEditor = parentDataEditor @@ -55,6 +54,7 @@ class TrackWidget(QtWidgets.QGroupBox): 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.soloCheckbox.clicked.connect(self.soloToggled) #only user changes, not through setChecked(). Which is important for "resetAllSolo" 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 @@ -148,6 +148,10 @@ class TrackWidget(QtWidgets.QGroupBox): assert signal == bool(self.ui.audibleCheckbox.checkState()) api.trackAudible(self.trackExportObject["id"], signal) + def soloToggled(self, signal): + assert signal == bool(self.ui.soloCheckbox.checkState()) + api.trackSolo(self.trackExportObject["id"], signal) + def doubleTrackToggled(self, signal): api.setDoubleTrack(self.trackExportObject["id"], signal) @@ -308,6 +312,7 @@ class TrackWidget(QtWidgets.QGroupBox): self.ui.doubleTrackCheckbox.setChecked(trackExportObject["double"]) self.ui.audibleCheckbox.setChecked(trackExportObject["audible"]) + self.ui.soloCheckbox.setChecked(trackExportObject["solo"]) for i in range(0,16): #without 16 #Engine from 0 to 15, GUI from 1 to 16. @@ -329,7 +334,7 @@ class TrackEditor(QtWidgets.QWidget): api.callbacks.updateBlockTrack.append(self.updateBlockList) self.tracks = {} #id:trackWidget. As any dict, not in order - #Add Track Button + #Add Track Buttons self.addTrackButton = QtWidgets.QPushButton("add track placeholder text") self.addTrackButton.clicked.connect(api.newEmptyTrack) self.layout.addWidget(self.addTrackButton) @@ -374,6 +379,12 @@ class TrackEditor(QtWidgets.QWidget): unfoldAllAdvanvced.clicked.connect(self.unfoldAllAdvanced) allUpbeatsLayout.addWidget(unfoldAllAdvanvced) + #Solo handling + resetAllSolo = QtWidgets.QPushButton(translate("trackEditorPythonFile", "Reset all Solo")) + resetAllSolo.clicked.connect(api.resetAllSolo) + allUpbeatsLayout.addWidget(resetAllSolo) + + def addTenTracks(self): for i in range(10): api.newEmptyTrack() diff --git a/qtgui/tracklistwidget.py b/qtgui/tracklistwidget.py index 273cf09..ffe44ab 100644 --- a/qtgui/tracklistwidget.py +++ b/qtgui/tracklistwidget.py @@ -41,26 +41,39 @@ class TrackListWidget(QtWidgets.QWidget): self.mainWindow = mainWindow self.layout = QtWidgets.QVBoxLayout(self) + self._currentlySelectedCursorExportDict = None + self.trackLabel = QtWidgets.QLabel("Hello") self.showEverythingCheckBox = QtWidgets.QCheckBox(QtCore.QCoreApplication.translate("TrackListWidget", "Show Everything")) self.showEverythingCheckBox.setChecked(True) self.showEverythingCheckBox.toggled.connect(self.reactShowEverything) + + self.soloPlaybackCheckbox = QtWidgets.QCheckBox(QtCore.QCoreApplication.translate("TrackListWidget", "Playback Solo")) + self.soloPlaybackCheckbox.toggled.connect(self.reactToggleSolo) + self.realTrackListWidget = _TrackListWidget(mainWindow, self) self.layout.addWidget(self.trackLabel) self.layout.addWidget(self.showEverythingCheckBox) + self.layout.addWidget(self.soloPlaybackCheckbox) self.layout.addWidget(self.realTrackListWidget, stretch=1) - api.callbacks.setCursor.append(self.setCursor) + api.callbacks.setCursor.append(self.callback_setCursor) def reactShowEverything(self, newState:bool): self.realTrackListWidget.showEverything(newState) - def setCursor(self, c:dict): + def callback_setCursor(self, c:dict): + self._currentlySelectedCursorExportDict = c self.trackLabel.setText( "{}-{}".format(c["trackIndex"]+1, c["trackName"]) ) + self.soloPlaybackCheckbox.blockSignals(True) #we don't want to trigger reactToggleSolo. Even if that is not recursive (qt prevents signals when nothing actually changed), it is a double call to the api with the same value + self.soloPlaybackCheckbox.setChecked(c["solo"]) + self.soloPlaybackCheckbox.blockSignals(False) - + def reactToggleSolo(self, newState:bool): + trId = self._currentlySelectedCursorExportDict["trackId"] + api.trackSolo(trId, newState) class _TrackListWidget(QtWidgets.QListWidget):