Browse Source

Add groups to tracks. Access via context menu

Nils 2 years ago
  1. 1
  2. 100
  3. 189
  4. 30
  5. 4
  6. 2083
  7. BIN
  8. 73
  9. 221
  10. 1
  11. 24


@ -4,6 +4,7 @@ Add option to change the midi channel for a track in the tracks context menu.
Add switch group places to song-structure context menus.
Add View-Menu with options to maximize the two editor areas.
Add advanced feature for a global rhythm offset, meant for Jack-Transport. Allows the whole piece to begin later on the jack-timeline.
Add track groups, which double as midi bus.
2021-01-15 Version 2.0.0
Big new features increase the MAJOR version. Old save files can still be loaded.


@ -485,9 +485,11 @@ def set_measuresPerGroup(value):
def changeTrackName(trackId, name):
"""The template gurantees a unique, sanitized name across tracks and groups"""
track =
session.history.register(lambda trId=trackId, changeTrackName(trId,v), descriptionString="Track Name") = " ".join(name.split())
if not name.lower() in (gr.lower() for gr in getGroups()):
session.history.register(lambda trId=trackId, changeTrackName(trId,v), descriptionString="Track Name") = name #sanitizes on its own. Checks for duplicate tracks but not groups
def changeTrackColor(trackId, colorInHex):
@ -519,9 +521,10 @@ def addTrack(scale=None):
assert track
trackId = id(track)
session.history.register(lambda trId=trackId: deleteTrack(trId), descriptionString="Add Track") #in place sorting for groups
def createSiblingTrack(trackId):
def createSiblingTrack(trackId): #aka clone track
"""Create a new track with scale, color and jack midi out the same as the given track.
The jack midi out will be independent after creation, but connected to the same instrument
(if any)"""
@ -531,9 +534,14 @@ def createSiblingTrack(trackId):
newTrack.pattern.averageVelocity = track.pattern.averageVelocity
newTrack.patternLengthMultiplicator = track.patternLengthMultiplicator
newTrack.midiChannel = track.midiChannel
jackConnections = cbox.JackIO.get_connected_ports(track.sequencerInterface.cboxPortName())
for port in jackConnections:
cbox.JackIO.port_connect(newTrack.sequencerInterface.cboxPortName(), port)
if, #includes
jackConnections = cbox.JackIO.get_connected_ports(track.sequencerInterface.cboxPortName())
for port in jackConnections:
cbox.JackIO.port_connect(newTrack.sequencerInterface.cboxPortName(), port)
#Move new track to neighbour the old one.
oldIndex =
newIndex =
@ -541,6 +549,7 @@ def createSiblingTrack(trackId):
assert newTrackAgain is newTrack, newTrackAgain)
session.history.register(lambda trId=id(newTrackAgain): deleteTrack(trId), descriptionString="Clone Track") #in place sorting for groups
return newTrack.export()
@ -568,13 +577,18 @@ def deleteTrack(trackId):
def moveTrack(trackId, newIndex):
"""index is 0 based"""
"""index is 0 based.
With groups involved free movevement is not allowed anymore.
All tracks of a group have to be next to each other and have to be in that exact place
in , so that jack metadata port order works, groups or not.
track =
oldIndex =
if not oldIndex == newIndex:
session.history.register(lambda tr=trackId, pos=oldIndex: moveTrack(trackId, pos), descriptionString="Move Track"), track) #in place sorting for groups
def setTrackPatternLengthMultiplicator(trackId, newMultiplicator:int):
@ -592,10 +606,80 @@ def setTrackPatternLengthMultiplicator(trackId, newMultiplicator:int):
#Track Groups
#Groups are dynamic. What groups exists and in which order is derived from the tracks themselves
def getGroups():
Returns an iterator of strings in order of the tracks.
Will return only existing groups, that contain at least one track"""
def setTrackGroup(trackId, groupName:str):
"""A not yet existing groupName will create that.
Set to empty string to create a standalone track"""
track =
groupName = ''.join(ch for ch in groupName if ch.isalnum()) #sanitize
groupName = " ".join(groupName.split()) #remove double spaces
if not == groupName:
if not groupName.lower() in ( for track in
session.history.register(lambda tr=trackId, setTrackGroup(trackId, v), descriptionString="Track Group"), groupName) #includes
def moveGroup(groupName:str, newIndex:int):
index is 0 based.
newIndex is like a track index. But instead of moving a single track we move all tracks
of one group to this position"""
#find tracks with that group.
#We assume they are all next to each other, because that is how auto-sorts tracks
groupMembers = [track for track in if == groupName]
firstGroupTrack = groupMembers[0]
firstGroupTrackIndex =
if firstGroupTrackIndex == newIndex:
session.history.register(lambda gr=groupName, pos=firstGroupTrackIndex: moveGroup(gr, pos), descriptionString="Move Group")
for offset, track in enumerate(groupMembers):
#We can't check and assert indices here because the list changes under our nose.
#assert firstGroupTrackIndex + offset ==, (firstGroupTrackIndex, offset,,
#popTr = + offset)
popTr =
#assert track is popTr, (track,, popTr, ), track) #in place sorting for groups
def setGroupVisible(groupName:str, force:bool=None):
"""A convenience function for the gui. just a flag that gets saved and loaded and changes
are reported via callback
Hides all tracks belonging to that track in reality. But we offer no way to hide a non-group
Calling without the force parameter to True/False toggles visibility.
groupMembers = [track for track in if == groupName]
for track in groupMembers:
if == groupName:
if not force is None:
track.visible = bool(force)
track.visible = not track.visible
#no need to update playback #in place sorting for groups
#Track Switches
#Aka measures
def _setTrackStructure(trackId, structure, undoMessage):
"""For undo. Used by all functions as entry point, then calls itself for undo/redo.
structure is a set of integers which we can copy with .copy()"""


@ -29,6 +29,7 @@ from calfbox import cbox
#Template Modules
from import Data as TemplateData
import template.engine.sequencer
from template.engine.sequencer import SequencerInterface #group tracks
from template.engine.duration import D4
from template.engine.pitch import simpleNoteNames
@ -64,6 +65,7 @@ class Data(template.engine.sequencer.Score):
self.cachedOffsetInTicks = 0
#Create three tracks with their first pattern activated, so 'play' after startup already produces sounding notes. This is less confusing for a new user.
#self.tracks is created in the template.
self.addTrack(name="Melody A", color="#ffff00")
self.addTrack(name="Chords A", color="#0055ff")
self.addTrack(name="Bass A", color="#00ff00")
@ -82,6 +84,19 @@ class Data(template.engine.sequencer.Score):
def _processAfterInit(self):
self._lastLoopStart = 0 #ticks. not saved
self.parentData = self # a trick to get SequencerInterface to play nicely with our groups
#After load each track has it's own groupless Sequencerinterface.
#self.groups itself is not saved.
#Restore groups:"Restoring saved groups")
self.groups = {} #NameString:SequencerInterface . Updated through function self.setGroup.
assert self.tracks #at least 1
self._duringFileLoadGroupIndicator = True
for track in self.tracks:
self.setGroup(track,, buildAllTracksAfterwards=False) #buildAllTracks is handled by the API initial callback/load engineStart
self._duringFileLoadGroupIndicator = False
def cacheOffsetInTicks(self):
oneMeasureInTicks = (self.howManyUnits * self.whatTypeOfUnit) / self.subdivisions
@ -93,6 +108,7 @@ class Data(template.engine.sequencer.Score):
"""Overrides the simpler template version"""
track = Track(parentData=self, name=name, scale=scale, color=color, simpleNoteNames=simpleNoteNames)
#self.sortTracks() no sortTracks here. This is used during file load! The api takes care of sortTracks
return track
def convertSubdivisions(self, value, errorHandling):
@ -163,6 +179,175 @@ class Data(template.engine.sequencer.Score):
self.subdivisions = value
return True
def _isGroupVisible(self, groupName:str):
for track in self.tracks:
if == groupName:
return track.visible
return None #not a group
def setGroup(self, track, groupName:str, buildAllTracksAfterwards=True):
"""Assigns a track to a group.
This is the only function that changes, be it to a group or empty string
for "standalone track".
We assume that groupName is a sanitized string. The api processes it as:
groupName = ''.join(ch for ch in groupName if ch.isalnum())
Group will be created if it does not exist yet,
otherwise the track will be attached to the existing group.
Track loses its existing standalone jack midi port OR its existing group connection
and is assigned to the new one.
Groups without connected tracks will be cleaned and their jack ports closed.
Tracks that lose their group to empty string will get a new jack port.
In the end we call buildAllTracks(), if not overriden by the parameter flag.
If you do several track-groups in a row (e.g. on file load) you don't need to call that
everytime. Once after you're done is enough.
#Simplest case first. If this is during load and this was a standalone track don't try to do anything group related
if self._duringFileLoadGroupIndicator and == "":
if (not self._duringFileLoadGroupIndicator) and == groupName: #no change. During file load these are the same. see secondinit
#Convert ex-group to standalone track
elif groupName == "" and not self._duringFileLoadGroupIndicator:
self.groups[].deleteSubtrack(id(track)) = ""
track.visible = True
#we never deleted the tracks standalone sequencerInterface. Reactivate."Re-activiating standalone SequencerInterface for track {}")
#Add track to new or existing group
if groupName in self.groups:
#We need to base track visibility on the other group members. But we cannot use the inner position in the group for that because the first one might already be the new track.
groupVisibility = self._isGroupVisible(groupName) #checking one track is enough. They are all the same, once the file is loaded.
assert not groupVisibility is None
else:"Creating new group SequencerInterface for {groupName}")
if not self._duringFileLoadGroupIndicator: #During file load tracks have groups already.
assert not groupName in [ for track in self.tracks if], ([ for track in self.tracks]) #no track should have that group yet
self.groups[groupName] = SequencerInterface(parentTrack=self, name=groupName) #we are not really a parentTrack, but it works.
groupVisibility = True #First member of a new group, everything stays visible. It already is, but this makes it easier to set self.visible below.
if == "" or self._duringFileLoadGroupIndicator: #Change from standalone to group"Deactiviating standalone SequencerInterface for track {}")
track.sequencerInterface.prepareForDeletion() #we do NOT delete the sequencerInterface. That has more data, such as the name!
else: #track was already in another group. But could also be file load!"Changing group for track {}")
if not self._duringFileLoadGroupIndicator:
#next export (called at bottom of this function) will use the new subtrack already
self.groups[groupName].setSubtrack(id(track), blobs=[(bytes(), 0, 0)]) = groupName #this switches the tracks midi generation to the subtrack based on its own id
if (not self._duringFileLoadGroupIndicator):
track.visible = groupVisibility
if not self._duringFileLoadGroupIndicator:
#Now that all tracks are set parse them all again to check if we have groups without tracks that need to get removed."Removing groups without tracks")
leftOverGroups = set(self.groups.keys())
for track in self.tracks:
if and in leftOverGroups:
for loGroup in leftOverGroups:"Group {loGroup} has no tracks left and will be deleted now.")
self.groups[loGroup].prepareForDeletion() #SequencerInterface.prepareForDeletion()
del self.groups[loGroup]
if buildAllTracksAfterwards:
def sortTracks(self):
"""Called by api.move and self.setGroup and all functions that either move tracks or
manipulate groups.
All tracks of a group must be neighbours in self.tracks.
The track order within a group is choice to the user.
We assume that self.groups is up to date.
self.groups = {} #NameString:SequencerInterface . Updated through function self.setGroup.
""""Sorting tracks and groups")
tempGroups = {}
listOfLists = [] #holds mutable instances of the same lists in tempGroups.values(). Standalone tracks are a list of len==1
tempGroupOrder = [] #just group names. We use it for a test below.
#Gather data
for track in self.tracks:
assert in self.groups
if not in tempGroupOrder:
sublist = []
tempGroups[] = sublist #sub-order per group
listOfLists.append(sublist) #mutable, same instances.
assert tempGroups[] is listOfLists[-1]
else: #standalone track.
#insert as group of one into listOfLists so we a sorted list, with a compatible format, later
l = []
track.visible = True # all standalone tracks are visible
#Assemble new track order
self._cachedTrackAndGroupOrderForJackMetadata = {}
counter = 0
newTracks = []
for grouplist in listOfLists:
for track in grouplist:
#Cache jack metadata port order
portname = self.groups[].cboxPortName()
if not portname in self._cachedTrackAndGroupOrderForJackMetadata: #did we already encounter this group?
self._cachedTrackAndGroupOrderForJackMetadata[portname] = counter
counter += 1
portname = track.sequencerInterface.cboxPortName()
self._cachedTrackAndGroupOrderForJackMetadata[portname] = counter
counter += 1
assert len(newTracks) == len(self.tracks), (newTracks, self.tracks)
assert set(newTracks) == set(self.tracks), (newTracks, self.tracks)
self.tracks = newTracks
#Test for data corruption. Not 100%, but combined with testing in self.groups above it is reasonably good.
assert len(tempGroupOrder) == len(tempGroups.keys()) == len(self.groups.keys()), (tempGroupOrder, tempGroups, self.groups)
#Override template function to include groups
def updateJackMetadataSorting(self):
"""Add this to you "tracksChanged" or "numberOfTracksChanged" callback.
Tell cbox to reorder the tracks by metadata. Deleted ports are automatically removed by JACK.
It is advised to use this in a controlled manner. There is no Score-internal check if
self.tracks changed and subsequent sorting. Multiple track changes in a row are common,
therefore the place to update jack order is in the API, where the new track order is also
sent to the UI.
We also check if the track is 'deactivated' by probing track.cboxMidiOutUuid.
Patroneo uses prepareForDeletion to deactive the tracks standalone track but keeps the
interface around for later use.
#order = {portName:index for index, portName in enumerate(track.sequencerInterface.cboxPortName() for track in self.tracks if track.sequencerInterface.cboxMidiOutUuid)}
order = self._cachedTrackAndGroupOrderForJackMetadata
except Exception as e: #No Jack Meta Data or Error with ports.
def buildAllTracks(self, buildSongDuration=False):
"""Includes all patterns.
@ -252,6 +437,8 @@ class Data(template.engine.sequencer.Score):
#Tracks depend on the rest of the data already in place because they create a cache on creation.
super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap
return self
def export(self):
@ -268,5 +455,3 @@ class Data(template.engine.sequencer.Score):
"globalOffsetMeasures" : self.globalOffsetMeasures,
"globalOffsetTicks" : self.globalOffsetTicks,


@ -58,6 +58,10 @@ class Track(object): #injection at the bottom of this file!
self.patternLengthMultiplicator = 1 #int. >= 1 the multiplicator is added after all other calculations, like subdivions. We can't integrate this into howManyUnits because that is the global score value
self.midiChannel = 0 # 0-15 midi channel is always set.
#2.1 = "" # "" is a standalone track, the normal one which existed since version 1.0. Using a name here will group these tracks together. A GUI can use this information. Also all tracks in a group share a single jack out port.
self.visible = True #only used together with groups. the api and our Datas setGroup function take care that standalone tracks are never hidden.
self.pattern = Pattern(parentTrack=self, scale=scale, simpleNoteNames=simpleNoteNames)
self.structure = structure if structure else set() #see buildTrack(). This is the main track data structure besides the pattern. Just integers (starts at 0) as switches which are positions where to play the patterns. In between are automatic rests.
self.whichPatternsAreScaleTransposed = whichPatternsAreScaleTransposed if whichPatternsAreScaleTransposed else {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!!
@ -73,6 +77,14 @@ class Track(object): #injection at the bottom of this file!
This is called after every small change.
#We still have an inactive sequencerinterface but instead we use the group ones as subtrack.
ourCalfboxTrack = self.parentData.groups[]._subtracks[id(self)].calfboxSubTrack
ourCalfboxTrack = self.sequencerInterface.calfboxTrack
assert ourCalfboxTrack, (self,, self.parentData, self.sequencerInterface)
#First clean the transpositions of zeroes
self.whichPatternsAreScaleTransposed = {k:v for k,v in self.whichPatternsAreScaleTransposed.items() if v!=0 and k in self.structure}
self.whichPatternsAreHalftoneTransposed = {k:v for k,v in self.whichPatternsAreHalftoneTransposed.items() if v!=0 and k in self.structure}
@ -81,7 +93,7 @@ class Track(object): #injection at the bottom of this file!
oneMeasureInTicks = int(oneMeasureInTicks) * self.patternLengthMultiplicator
filteredStructure = [index for index in sorted(self.structure) if index < self.parentData.numberOfMeasures] #not <= because we compare count with range
cboxclips = [o.clip for o in self.sequencerInterface.calfboxTrack.status().clips]
cboxclips = [o.clip for o in ourCalfboxTrack.status().clips]
globalOffset = self.parentData.cachedOffsetInTicks
@ -91,7 +103,7 @@ class Track(object): #injection at the bottom of this file!
scaleTransposition = self.whichPatternsAreScaleTransposed[index] if index in self.whichPatternsAreScaleTransposed else 0
halftoneTransposition = self.whichPatternsAreHalftoneTransposed[index] if index in self.whichPatternsAreHalftoneTransposed else 0
cboxPattern = self.pattern.buildPattern(scaleTransposition, halftoneTransposition, self.parentData.howManyUnits, self.parentData.whatTypeOfUnit, self.parentData.subdivisions, self.patternLengthMultiplicator)
r = self.sequencerInterface.calfboxTrack.add_clip(globalOffset + index*oneMeasureInTicks, 0, oneMeasureInTicks, cboxPattern) #pos, pattern-internal offset, length, pattern.
r = ourCalfboxTrack.add_clip(globalOffset + index*oneMeasureInTicks, 0, oneMeasureInTicks, cboxPattern) #pos, pattern-internal offset, length, pattern.
######Old optimisations. Keep for later####
@ -113,6 +125,8 @@ class Track(object): #injection at the bottom of this file!
"whichPatternsAreHalftoneTransposed" : self.whichPatternsAreHalftoneTransposed,
"patternLengthMultiplicator" : self.patternLengthMultiplicator,
"midiChannel" : self.midiChannel,
"group" :,
"visible" : self.visible,
@ -132,6 +146,16 @@ class Track(object): #injection at the bottom of this file!
self.midiChannel = 0
if "group" in serializedData: = serializedData["group"]
else: = "" #standalone track
if "visible" in serializedData:
self.visible = serializedData["visible"]
self.visible = True
self.color = serializedData["color"]
self.structure = set(serializedData["structure"])
self.whichPatternsAreHalftoneTransposed = {int(k):int(v) for k,v in serializedData["whichPatternsAreHalftoneTransposed"].items()} #json saves dict keys as strings
@ -156,6 +180,8 @@ class Track(object): #injection at the bottom of this file!
"whichPatternsAreScaleTransposed": self.whichPatternsAreScaleTransposed,
"whichPatternsAreHalftoneTransposed": self.whichPatternsAreHalftoneTransposed,
"midiChannel" : self.midiChannel+1, #1-16
"group" :, #string
"visible" : self.visible, #bool. Always True for standalone tracks
#Dependency Injections.


@ -90,8 +90,8 @@ class MainWindow(TemplateMainWindow):
QtCore.QT_TRANSLATE_NOOP("NOOPengineHistory", "Change Pattern Velocity")
QtCore.QT_TRANSLATE_NOOP("NOOPengineHistory", "Number of Notes in Pattern")
QtCore.QT_TRANSLATE_NOOP("NOOPengineHistory", "Global Rhythm Offset")
QtCore.QT_TRANSLATE_NOOP("NOOPengineHistory", "Track Group")
QtCore.QT_TRANSLATE_NOOP("NOOPengineHistory", "Move Group")
def __init__(self):
"""The order of calls is very important.


File diff suppressed because it is too large


Binary file not shown.


@ -72,6 +72,14 @@
<translation>Reihe mit Wiederholung bis Schritt {} befüllen</translation>
<location filename="../../" line="1050"/>
<source>grab and move to reorder groups</source>
<translation>mit der maus halten und ziehen um Gruppen anzuordnen</translation>
@ -345,6 +353,16 @@
<source>Global Rhythm Offset</source>
<translation>Allgemeiner Rhythmusversatz</translation>
<location filename="../../" line="93"/>
<source>Track Group</source>
<location filename="../../" line="94"/>
<source>Move Group</source>
<translation>Verschiebe Gruppe</translation>
@ -455,27 +473,27 @@
<translation type="obsolete">Lösche {} Takte von Takt {} beginnend</translation>
<location filename="../../" line="378"/>
<location filename="../../" line="399"/>
<source>Insert empty group before this one</source>
<translation>Leere Taktgruppe vor dieser einfügen</translation>
<location filename="../../" line="380"/>
<location filename="../../" line="401"/>
<source>Delete whole group</source>
<translation>Lösche diese Taktgruppe</translation>
<location filename="../../" line="381"/>
<location filename="../../" line="402"/>
<source>Duplicate whole group including measures</source>
<translation>Verdopple diese Taktgruppe inkl. Struktur</translation>
<location filename="../../" line="382"/>
<location filename="../../" line="403"/>
<source>Clear all group transpositions</source>
<translation>Setze alle Transpositionen dieser Taktgruppe zurück</translation>
<location filename="../../" line="379"/>
<location filename="../../" line="400"/>
<source>Exchange group with right neigbour</source>
<translation>Tausche Gruppe mit rechter Nachbargruppe</translation>
@ -607,12 +625,12 @@
<location filename="../../" line="793"/>
<location filename="../../" line="856"/>
<source>grab and move to reorder tracks</source>
<translation>mit der maus halten und ziehen um Spuren anzuordnen</translation>
<location filename="../../" line="802"/>
<location filename="../../" line="865"/>
<source>change track color</source>
<translation>setze Farbe der Spur</translation>
@ -620,27 +638,27 @@
<location filename="../../" line="718"/>
<location filename="../../" line="760"/>
<source>Invert Measures</source>
<translation>Taktauswahl umdrehen</translation>
<location filename="../../" line="719"/>
<location filename="../../" line="761"/>
<source>All Measures On</source>
<translation>Alle Takte anschalten</translation>
<location filename="../../" line="720"/>
<location filename="../../" line="762"/>
<source>All Measures Off</source>
<translation>Alle Takte ausschalten</translation>
<location filename="../../" line="721"/>
<location filename="../../" line="763"/>
<source>Clone this Track</source>
<translation>Spur klonen</translation>
<location filename="../../" line="722"/>
<location filename="../../" line="764"/>
<source>Delete Track</source>
<translation>Spur löschen</translation>
@ -650,20 +668,45 @@
<translation type="obsolete">Übernimm Struktur von</translation>
<location filename="../../" line="739"/>
<location filename="../../" line="781"/>
<source>Merge/Copy Measure-Structure from</source>
<translation>Übernimm und ergänze Struktur von</translation>
<location filename="../../" line="753"/>
<location filename="../../" line="795"/>
<source>Replace Pattern with</source>
<translation>Ersetze Noten des Taktes durch</translation>
<location filename="../../" line="767"/>
<location filename="../../" line="809"/>
<source>Send on MIDI Channel</source>
<translation>Sende auf MIDI Kanal</translation>
<location filename="../../" line="737"/>
<source>Group Name</source>
<location filename="../../" line="738"/>
<source>Create a new group by name</source>
<translation>Neue Gruppe erstellen</translation>
<location filename="../../" line="819"/>
<location filename="../../" line="820"/>
<source>New Group</source>
<translation>Neue Gruppe</translation>
<location filename="../../" line="825"/>
<source>Remove from </source>
<translation>Aus Gruppe entfernen: </translation>


@ -33,7 +33,8 @@ SIZE_TOP_OFFSET = 0
_zValuesRelativeToScene = { #Only use for objects added directly to the scene, not children.
"group" : 6,
@ -54,6 +55,7 @@ class SongEditor(QtWidgets.QGraphicsScene):
self._groupRectangles = []
self.tracks = {} #TrackID:TrackStructures
self.barlines = [] #in order
@ -104,10 +106,17 @@ class SongEditor(QtWidgets.QGraphicsScene):
self.ticksToPixelRatio = oneMeasureInTicks / SIZE_UNIT
def callback_numberOfTracksChanged(self, exportDictList):
"""Used for new tracks, delete track and move track"""
"""Used for new tracks, delete track and move track. Also groups and visibility."""
toDelete = set(self.tracks.keys())
self.trackOrder = []
groupsSeen = set() #check if we already know this group
for grect in self._groupRectangles:
self.removeItem(grect) #group rectangles are direct children of the scene. delete them here.
self._groupRectangles = []
groupOffset = 0 #pixels. It is a positive/absolute value
hiddenOffsetCounter = 0 #counts hidden tracks in pixels. It is a positive/absolute value
for index, exportDict in enumerate(exportDictList):
if exportDict["id"] in self.tracks:
toDelete.remove(exportDict["id"]) #keep this track and don't delete later.
@ -116,8 +125,28 @@ class SongEditor(QtWidgets.QGraphicsScene):
if exportDict["group"]:
if not exportDict["group"] in groupsSeen: #first encounter
groupRect = QtWidgets.QGraphicsRectItem(0,0, exportDict["numberOfMeasures"]*SIZE_UNIT, SIZE_UNIT)
role = QtGui.QPalette.Window
c = self.parentView.parentMainWindow.fPalBlue.color(role)
groupRect.setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter)
groupOffset = len(groupsSeen) * SIZE_UNIT
if exportDict["visible"]:
hiddenOffsetCounter += SIZE_UNIT
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET)
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter)
@ -132,7 +161,7 @@ class SongEditor(QtWidgets.QGraphicsScene):
del trackStructure
assert all(track.exportDict["sequencerInterface"]["index"] == self.trackOrder.index(track) for track in self.tracks.values())
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT + groupOffset - hiddenOffsetCounter
self.setSceneRect(0,0,exportDict["numberOfMeasures"]*SIZE_UNIT,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET) #no empty space on top without a scene rect. Also a bit of leniance.
self.playhead.setLine(0, 0, 0, self.cachedCombinedTrackHeight) #(x1, y1, x2, y2)
@ -197,7 +226,6 @@ class SongEditor(QtWidgets.QGraphicsScene):
track = self.tracks[exportDict["id"]]
class TrackStructure(QtWidgets.QGraphicsRectItem):
"""From left to right. Holds two lines to show the "staffline" and a number of switches,
colored rectangles to indicate where a pattern is activated on the timeline"""
@ -335,7 +363,10 @@ class TrackStructure(QtWidgets.QGraphicsRectItem):
r.setRight(SIZE_UNIT * factor)
vis = self.exportDict["visible"]
for position, switch in self.switches.items():
#Deal with measures that stretch multiple base measures
switch.setPos(position * SIZE_UNIT * factor, self.y())
@ -360,6 +391,9 @@ class TrackStructure(QtWidgets.QGraphicsRectItem):
if not vis:
def contextMenuEvent(self, event):
if self._mousePressOn: #Right click can happen while the left button is still pressed down, which we don't want.
@ -634,6 +668,7 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene):
self.parentView = parentView
self.tracks = {} #TrackID:TrackLabel
self.groups = [] #GroupLabel()
self._cachedExportDictsInOrder = []
self._exportDictScore = None #cache
@ -662,14 +697,19 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene):
self._exportDictScore = exportDictScore
def callback_numberOfTracksChanged(self, exportDictList):
groupsSeen = set() #check if we already know this group
toDelete = set(self.tracks.keys())
self._cachedExportDictsInOrder = exportDictList
width = self.parentView.geometry().width()
#clean group labels. Will be recreated below
for group in self.groups:
groupOffset = 0 #pixels. It is a positive/absolute value
hiddenOffsetCounter = 0 #counts hidden tracks in pixels. It is a positive/absolute value
for index, exportDict in enumerate(exportDictList):
if exportDict["id"] in self.tracks:
@ -677,9 +717,25 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene):
self.tracks[exportDict["id"]] = TrackLabel(parentScene=self, width=width, height=SIZE_UNIT)
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET)
if exportDict["group"]:
if not exportDict["group"] in groupsSeen: #first encounter
grouplabel = GroupLabel(parentScene=self, width=width, height=SIZE_UNIT, name=exportDict["group"], visible=exportDict["visible"])
grouplabel.setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter) #group offset is still "above" the current group. If this is a group itself the offset will be extended just belowt to make room for ourselves.
groupOffset = len(groupsSeen) * SIZE_UNIT
if exportDict["visible"]:
hiddenOffsetCounter += SIZE_UNIT
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter)
#We had this tracks in the GUI but they are gone in the export. This is track delete.
for trackId in toDelete:
@ -691,13 +747,20 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene):
anyExistingTrack = next(iter(self.tracks.values()))
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT + groupOffset - hiddenOffsetCounter
self.setSceneRect(0,0,width-1,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET)
def callback_patternLengthMultiplicatorChanged(self, exportDict):
self.tracks[exportDict["id"]].update(exportDict) #general update function that also covers our value
def tellApiToCreateNewGroupForTrack(self, trackId):
title = QtCore.QCoreApplication.translate("TrackLabelContext", "Group Name")
info = QtCore.QCoreApplication.translate("TrackLabelContext", "Create a new group by name")
result, ok = QtWidgets.QInputDialog.getText(self.parentView, title, info) #parent, titlebar, info-text
if ok:
api.setTrackGroup(trackId, str(result))
def contextMenuEvent(self, event):
We can't delete this properly object from within. The engine callback will react faster
@ -707,10 +770,16 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene):
menu = QtWidgets.QMenu()
item = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
if not type(item) is QtWidgets.QGraphicsProxyWidget:
if not item:
if type(item) is TrackLabel:
exportDict = item.exportDict.copy()
elif type(item.parentItem()) is TrackLabel:
exportDict = item.parentItem().exportDict.copy()
return None
exportDict = item.parentItem().exportDict.copy()
del item #yes, manual memory management in Python. We need to get rid of anything we want to delete later or Qt will crash because we have a python wrapper without a qt object
listOfLabelsAndFunctions = [
@ -773,6 +842,27 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene):
#Add a submenu for track groups. Will call the api which will send us a callback to reorder the tracks.
groupMenu = menu.addMenu(QtCore.QCoreApplication.translate("TrackLabelContext", "Group"))
newGroupAction = QtWidgets.QAction(QtCore.QCoreApplication.translate("TrackLabelContext", "New Group"), groupMenu)
newGroupAction.triggered.connect(lambda: self.tellApiToCreateNewGroupForTrack(exportDict["id"]))
if exportDict["group"]:
removeGroupAction = QtWidgets.QAction(QtCore.QCoreApplication.translate("TrackLabelContext", "Remove from ")+exportDict['group'], groupMenu)
removeGroupAction.triggered.connect(lambda: api.setTrackGroup(exportDict["id"], "")) #empty string = no group
#Offer existing groups
for groupString in api.getGroups():
grpAction = QtWidgets.QAction(groupString, groupMenu)
midiChannelCommand = lambda discard, grpArg=groupString: api.setTrackGroup(exportDict["id"], grpArg) #discard parameter given by QAction
if exportDict["group"] == groupString:
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
@ -960,6 +1050,109 @@ class TrackLabel(QtWidgets.QGraphicsRectItem):
c = self.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
class GroupLabel(QtWidgets.QGraphicsRectItem):
"""Compatible with TrackLabel but stripped down:
No name change, no color, no multiplicator. But
a name that you can drag around
Group Labels get deleted and recreated on each change.
def __init__(self, parentScene, width, height, name, visible):
super().__init__(0, 0, width, height)
self.parentScene = parentScene = name
self.visible = visible #if that changes it will change only on creation of a GroupLabel instance
self.setFlag(self.ItemIgnoresTransformations) #zoom will repostion but not make the font bigger.
c = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.Window)
#Child widgets. SIZE_UNIT is used as positioning block. Multiply yourself :)
self._duringGroupMove = False
self.positioningHandle = GroupLabel.PositioningHandle(parentGroupLabel=self)
self.positioningHandle.setToolTip(QtCore.QCoreApplication.translate("GroupLabel", "grab and move to reorder groups"))
if visible:
name = "" + name
name = "" + name
self.qLabel = QtWidgets.QLabel(name)
self.label = QtWidgets.QGraphicsProxyWidget()
self.qLabel.setMinimumSize(QtCore.QSize(0, SIZE_UNIT))
self.qLabel.setStyleSheet("background-color: rgba(0,0,0,0)") #transparent so we see the RectItem color
def mousePressEvent(self,event):
"""Without this no PositionHandle mouseMove and mouse Release events!!!
Also no double click"""
if not self.positioningHandle._cursorPosOnMoveStart: #during group move
api.setGroupVisible( #calling with one parameter toggles visibility.
#def mouseDoubleClickEvent(self, event):
# event.accept()
class PositioningHandle(QtWidgets.QGraphicsEllipseItem):
def __init__(self, parentGroupLabel):
self.parentGroupLabel = parentGroupLabel
role = QtGui.QPalette.ToolTipBase
c = self.parentGroupLabel.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
self.setOpacity(0.08) #this is meant as a slight overlay/highlight of both the current track and the other tracks
self.arrowLabel = QtWidgets.QGraphicsSimpleTextItem("")
role = QtGui.QPalette.Text
self._cursorPosOnMoveStart = None
def yPos2trackIndex(self, y):
"""0 based"""
pos = round(y / SIZE_UNIT)
pos = min(pos, len(self.parentGroupLabel.parentScene.tracks)-1)
return pos
def mouseMoveEvent(self, event):
if self._cursorPosOnMoveStart:
self.parentGroupLabel.setY(max(0, event.scenePos().y()))
#super().mouseMoveEvent(event) #with this the sync between cursor and item is off.
def mousePressEvent(self, event):
"""release gets only triggered when mousePressEvent was on the same item.
We don't need to worry about the user just releasing the mouse on this item"""
self._posBeforeMove = self.parentGroupLabel.pos()
self._cursorPosOnMoveStart = QtGui.QCursor.pos()
#super().mousePressEvent(event) #with this in mouseMoveEvent does not work. IIRC because we do not set the movableFlag
def mouseReleaseEvent(self, event):
newIndex = self.yPos2trackIndex(self.parentGroupLabel.y()) #we need to save that first, right after this we reset the position
self.parentGroupLabel.setPos(self._posBeforeMove) #In case the block was moved to a position where no track is (below the tracks) we just reset the graphics before anything happens. The user will never see this really
self._posBeforeMove = None
self._cursorPosOnMoveStart = None
api.moveGroup(, newIndex)
class Playhead(QtWidgets.QGraphicsLineItem):
def __init__(self, parentScene):
super().__init__(0, 0, 0, 0) # (x1, y1, x2, y2)
@ -967,6 +1160,7 @@ class Playhead(QtWidgets.QGraphicsLineItem):
p = QtGui.QPen()
#PlayHead height is set in SongEditor.callback_numberOfTracksChanged.
@ -974,7 +1168,10 @@ class Playhead(QtWidgets.QGraphicsLineItem):
def setCursorPosition(self, tickindex, playbackStatus):
"""Set the playhead to the right position, but keep the viewport stable.
Shift the entire "page" if the cursor becomes invisible because its steps outside the viewport"""
Shift the entire "page" if the cursor becomes invisible because its steps outside the viewport.
PlayHead height is set in SongEditor.callback_numberOfTracksChanged.
x = tickindex / self.parentScene.ticksToPixelRatio
if playbackStatus: # api.duringPlayback:


@ -239,7 +239,6 @@ class Callbacks(object):
"""New track, delete track, reorder
Sent the current track order as list of ids, combined with their structure.
This is also used when tracks get created or deleted, also on initial load.
This also includes the pattern.
lst = [track.export() for track in]


@ -107,12 +107,16 @@ class Score(Data):
self.tracks changed and subsequent sorting. Multiple track changes in a row are common,
therefore the place to update jack order is in the API, where the new track order is also
sent to the UI.
We also check if the track is 'deactivated' by probing track.cboxMidiOutUuid.
Patroneo uses prepareForDeletion to deactive the tracks standalone track but keeps the
interface around for later use.
order = {portName:index for index, portName in enumerate(track.sequencerInterface.cboxPortName() for track in self.tracks)}
order = {portName:index for index, portName in enumerate(track.sequencerInterface.cboxPortName() for track in self.tracks if track.sequencerInterface.cboxMidiOutUuid)}
except Exception as e: #No Jack Meta Data
except Exception as e: #No Jack Meta Data or Error with ports.
def trackById(self, trackId:int):
@ -197,6 +201,8 @@ class _Interface(object):
def _isNameAvailable(self, name:str):
"""Check if the name is free. If not increment"""
name = ''.join(ch for ch in name if ch.isalnum() or ch in (" ", "_", "-")) #sanitize
name = " ".join(name.split()) #remove double spaces
while name in [ for tr in self.parentData.tracks]:
beforeLastChar = name[-2]
lastChar = name[-1]
@ -321,7 +327,7 @@ class SequencerInterface(_Interface): #Basically the midi part of a track.
def _processAfterInit(self):
#Create midi out and cbox track"Creating empty SequencerInterface instance")"Creating empty SequencerInterface instance for {self._name}")
self.cboxMidiOutUuid = cbox.JackIO.create_midi_output(self._name)
@ -348,7 +354,8 @@ class SequencerInterface(_Interface): #Basically the midi part of a track.
def name(self, value):
if not value in ( for track in self.parentData.tracks):
self._name = self._isNameAvailable(value)
cbox.JackIO.rename_midi_output(self.cboxMidiOutUuid, self._name)
if self.cboxMidiOutUuid: #we could be deactivated
cbox.JackIO.rename_midi_output(self.cboxMidiOutUuid, self._name)
def cboxPortName(self)->str:
"""Return the complete jack portname: OurName:PortName"""
@ -413,6 +420,15 @@ class SequencerInterface(_Interface): #Basically the midi part of a track.
def deleteSubtrack(self, key):
"""Remove a subtrack.
Return for a potential undo"""
toDelete = self._subtracks[key]
del self._subtracks[key]
return toDelete
#Save / Load / Export
def serialize(self)->dict:
"""Generate Data to save as json"""