Browse Source

Add SOLO functionality alongside the existing audible/mute layer. Control via shortcuts, track editor, or track list widget

master
Nils 2 months ago
parent
commit
b9765d3fbd
  1. 2
      CHANGELOG
  2. 47
      engine/api.py
  3. 1
      engine/cursor.py
  4. 41
      engine/main.py
  5. 30
      engine/track.py
  6. 11
      qtgui/designer/mainwindow.py
  7. 15
      qtgui/designer/mainwindow.ui
  8. 8
      qtgui/designer/trackWidget.py
  9. 9
      qtgui/designer/trackWidget.ui
  10. 3
      qtgui/menu.py
  11. 15
      qtgui/trackEditor.py
  12. 19
      qtgui/tracklistwidget.py

2
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

47
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()

1
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,

41
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__

30
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,

11
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"))

15
qtgui/designer/mainwindow.ui

@ -161,6 +161,8 @@
<addaction name="actionAdd_Track"/>
<addaction name="actionDelete_Current_Track"/>
<addaction name="actionUse_Current_Track_as_Metronome"/>
<addaction name="actionToggle_Track_Solo_Playback"/>
<addaction name="actionReset_all_Solo"/>
<addaction name="separator"/>
<addaction name="actionBlock_Properties"/>
<addaction name="actionSplit_Current_Block"/>
@ -1861,6 +1863,19 @@
<string>Ctrl+Shift+D</string>
</property>
</action>
<action name="actionToggle_Track_Solo_Playback">
<property name="text">
<string>Toggle Track Solo Playback</string>
</property>
<property name="shortcut">
<string>Z</string>
</property>
</action>
<action name="actionReset_all_Solo">
<property name="text">
<string>Reset Solo for all Tracks</string>
</property>
</action>
</widget>
<resources/>
<connections/>

8
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"))

9
qtgui/designer/trackWidget.ui

@ -6,7 +6,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>1419</width>
<width>1473</width>
<height>548</height>
</rect>
</property>
@ -179,6 +179,13 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="soloCheckbox">
<property name="text">
<string>Solo</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="visibleCheckbox">
<property name="text">

3
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,

15
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()

19
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( "<b>{}-{}</b>".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):

Loading…
Cancel
Save