From b24394e681b9214350755f0b43dd35d44dd94b5a Mon Sep 17 00:00:00 2001 From: Nils <> Date: Sat, 13 Feb 2021 17:41:55 +0100 Subject: [PATCH] guard against a rare crash that happens when a qt widget tries to access a just deleted track, because the widget itself got deleted which triggered an out-of-focus-send-to-engine event --- engine/api.py | 38 ++++++++++++++++++++++++++++++++++-- template/engine/sequencer.py | 13 +++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/engine/api.py b/engine/api.py index 2ae49f9..473964b 100644 --- a/engine/api.py +++ b/engine/api.py @@ -475,6 +475,7 @@ def set_measuresPerGroup(value): def changeTrackName(trackId, name): """The template gurantees a unique, sanitized name across tracks and groups""" track = session.data.trackById(trackId) + if not track: return if not name.lower() in (gr.lower() for gr in getGroups()): session.history.register(lambda trId=trackId, v=track.sequencerInterface.name: changeTrackName(trId,v), descriptionString="Track Name") track.sequencerInterface.name = name #sanitizes on its own. Checks for duplicate tracks but not groups @@ -483,6 +484,7 @@ def changeTrackName(trackId, name): def changeTrackColor(trackId, colorInHex): """Expects "#rrggbb""" track = session.data.trackById(trackId) + if not track: return assert len(colorInHex) == 7, colorInHex session.history.register(lambda trId=trackId, v=track.color: changeTrackColor(trId,v), descriptionString="Track Color") track.color = colorInHex @@ -495,6 +497,7 @@ def changeTrackMidiChannel(trackId, newChannel:int): logger.warning(f"Midi Channel must be between 1-16 for this function, was: {newChannel}. Doing nothing.") return track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.midiChannel: changeTrackMidiChannel(trId,v), descriptionString="Track Midi Channel") track.midiChannel = newChannel-1 track.pattern.buildExportCache() @@ -517,6 +520,7 @@ def createSiblingTrack(trackId): #aka clone track The jack midi out will be independent after creation, but connected to the same instrument (if any)""" track = session.data.trackById(trackId) + if not track: return assert type(track.pattern.scale) == tuple newTrack = session.data.addTrack(name=track.sequencerInterface.name, scale=track.pattern.scale, color=track.color, simpleNoteNames=track.pattern.simpleNoteNames) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2" newTrack.pattern.averageVelocity = track.pattern.averageVelocity @@ -551,6 +555,7 @@ def _reinsertDeletedTrack(track, trackIndex): def deleteTrack(trackId): track = session.data.trackById(trackId) + if not track: return oldIndex = session.data.tracks.index(track) with session.history.sequence("Delete Track"): setTrackGroup(trackId, "") #has it's own undo @@ -562,8 +567,6 @@ def deleteTrack(trackId): else: session.history.register(lambda tr=deletedTrack, pos=oldIndex: _reinsertDeletedTrack(tr, pos), descriptionString="Delete Track") - print (session.data.groups) - updatePlayback() callbacks._numberOfTracksChanged() @@ -574,6 +577,7 @@ def moveTrack(trackId, newIndex): in session.data.tracks , so that jack metadata port order works, groups or not. """ track = session.data.trackById(trackId) + if not track: return oldIndex = session.data.tracks.index(track) if not oldIndex == newIndex: session.history.register(lambda tr=trackId, pos=oldIndex: moveTrack(trackId, pos), descriptionString="Move Track") @@ -587,6 +591,7 @@ def setTrackPatternLengthMultiplicator(trackId, newMultiplicator:int): return #Invalid input track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.patternLengthMultiplicator: setTrackPatternLengthMultiplicator(trackId, v), descriptionString="Pattern Multiplier") track.patternLengthMultiplicator = newMultiplicator track.pattern.buildExportCache() @@ -610,6 +615,7 @@ def setTrackGroup(trackId, groupName:str): """A not yet existing groupName will create that. Set to empty string to create a standalone track""" track = session.data.trackById(trackId) + if not track: return groupName = ''.join(ch for ch in groupName if ch.isalnum()) #sanitize groupName = " ".join(groupName.split()) #remove double spaces if not track.group == groupName: @@ -675,6 +681,7 @@ 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()""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.structure.copy(), msg=undoMessage: _setTrackStructure(trackId, v, msg), descriptionString=undoMessage) @@ -687,6 +694,7 @@ def _setTrackStructure(trackId, structure, undoMessage): def setSwitches(trackId, setOfPositions, newBool): """Used in the GUI to select multiple switches in a row by dragging the mouse""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.structure.copy(): _setTrackStructure(trackId, v, "Set Measures"), descriptionString="Set Measures") if newBool: track.structure = track.structure.union(setOfPositions) #merge: add setOfPositions to the existing one @@ -698,6 +706,7 @@ def setSwitches(trackId, setOfPositions, newBool): def setSwitch(trackId, position, newBool): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Set Measures"), descriptionString="Set Measures") if newBool: if position in track.structure: return @@ -712,6 +721,7 @@ def setSwitch(trackId, position, newBool): def trackInvertSwitches(trackId): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Invert Measures"), descriptionString="Invert Measures") """ if track.structure: @@ -728,6 +738,7 @@ def trackInvertSwitches(trackId): def trackOffAllSwitches(trackId): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Track Measures Off"), descriptionString="Track Measures Off") track.structure = set() track.buildTrack() @@ -736,6 +747,7 @@ def trackOffAllSwitches(trackId): def trackOnAllSwitches(trackId): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Track Measures On"), descriptionString="Track Measures On") track.structure = set(i for i in range(session.data.numberOfMeasures)) track.buildTrack() @@ -773,6 +785,7 @@ def _setSwitchesScaleTranspose(trackId, whichPatternsAreScaleTransposed): """For undo. Used by all functions as entry point, then calls itself for undo/redo. whichPatternsAreScaleTransposed is a dicts of int:int which we can copy with .copy()""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.whichPatternsAreScaleTransposed.copy(): _setSwitchesScaleTranspose(trackId, v), descriptionString="Set Modal Shift") @@ -785,6 +798,7 @@ def _setSwitchesScaleTranspose(trackId, whichPatternsAreScaleTransposed): def setSwitchScaleTranspose(trackId, position, transpose): """Scale transposition is flipped. lower value means higher pitch""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.whichPatternsAreScaleTransposed.copy(): _setSwitchesScaleTranspose(trackId, v), descriptionString="Set Modal Shift") track.whichPatternsAreScaleTransposed[position] = transpose track.buildTrack() @@ -797,6 +811,7 @@ def _setSwitchHalftoneTranspose(trackId, whichPatternsAreHalftoneTransposed): """For undo. Used by all functions as entry point, then calls itself for undo/redo. whichPatternsAreScaleTransposed is a dicts of int:int which we can copy with .copy()""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.whichPatternsAreHalftoneTransposed.copy(): _setSwitchHalftoneTranspose(trackId, v), descriptionString="Set Half Tone Shift") @@ -808,6 +823,7 @@ def _setSwitchHalftoneTranspose(trackId, whichPatternsAreHalftoneTransposed): def setSwitchHalftoneTranspose(trackId, position, transpose): """Halftone transposition is not flipped. Higher value means higher pitch""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda tr=trackId, v=track.whichPatternsAreHalftoneTransposed.copy(): _setSwitchHalftoneTranspose(trackId, v), descriptionString="Set Half Tone Shift") track.whichPatternsAreHalftoneTransposed[position] = transpose track.buildTrack() @@ -1015,6 +1031,7 @@ def setPattern(trackId, patternList, undoMessage): It is rarely used directly by the GUI, if at all. Normal changes are atomic. """ track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(), msg=undoMessage: setPattern(trId, v, msg), descriptionString=undoMessage) @@ -1037,6 +1054,7 @@ def setStep(trackId, stepExportDict): This is also for velocity! """ track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Step"), descriptionString="Change Step") oldNote = track.pattern.stepByIndexAndPitch(index=stepExportDict["index"], pitch=stepExportDict["pitch"]) if oldNote: #modify existing note @@ -1054,6 +1072,7 @@ def setStep(trackId, stepExportDict): def removeStep(trackId, index, pitch): """Reverse of setStep""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Remove Step"), descriptionString="Remove Step") oldNote = track.pattern.stepByIndexAndPitch(index, pitch) track.pattern.data.remove(oldNote) @@ -1066,6 +1085,7 @@ def setScale(trackId, scale, callback = True): """Expects a scale list or tuple from lowest index to highest. Actual pitches don't matter.""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.scale[:]: setScale(trId, v, callback=True), descriptionString="Set Scale") track.pattern.scale = scale #tuple, or list if oversight in json loading :) track.pattern.buildExportCache() @@ -1078,12 +1098,14 @@ def setSimpleNoteNames(trackId, simpleNoteNames): """note names is a list of strings with length 128. One name for each midi note. It is saved to file""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.simpleNoteNames[:]: setSimpleNoteNames(trId, v), descriptionString="Note Names") track.pattern.simpleNoteNames = simpleNoteNames #list of strings callbacks._trackMetaDataChanged(track) def transposeHalftoneSteps(trackId, steps): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.scale[:]: setScale(trId, v, callback=True), descriptionString="Transpose Scale") track.pattern.scale = [midipitch+steps for midipitch in track.pattern.scale] track.pattern.buildExportCache() @@ -1093,6 +1115,7 @@ def transposeHalftoneSteps(trackId, steps): def patternInvertSteps(trackId): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Invert Steps"), descriptionString="Invert Steps") track.pattern.invert() track.pattern.buildExportCache() @@ -1102,6 +1125,7 @@ def patternInvertSteps(trackId): def patternOnAllSteps(trackId): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "All Steps On"), descriptionString="All Steps On") track.pattern.fill() track.pattern.buildExportCache() @@ -1111,6 +1135,7 @@ def patternOnAllSteps(trackId): def patternOffAllSteps(trackId): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "All Steps Off"), descriptionString="All Steps Off") track.pattern.empty() track.pattern.buildExportCache() @@ -1121,6 +1146,7 @@ def patternOffAllSteps(trackId): def patternInvertRow(trackId, pitchindex): """Pitchindex is the row""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Invert Row"), descriptionString="Invert Row") track.pattern.invertRow(pitchindex) track.pattern.buildExportCache() @@ -1132,6 +1158,7 @@ def patternClearRow(trackId, pitchindex): """Pitchindex is the row. Index is the column""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Clear Row"), descriptionString="Clear Row") track.pattern.clearRow(pitchindex) track.pattern.buildExportCache() @@ -1143,6 +1170,7 @@ def patternRowRepeatFromStep(trackId, pitchindex, index): """Pitchindex is the row. Index is the column""" track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Fill Row with Repeat"), descriptionString="Fill Row with Repeat") track.pattern.repeatFromStep(pitchindex, index) track.pattern.buildExportCache() @@ -1152,6 +1180,7 @@ def patternRowRepeatFromStep(trackId, pitchindex, index): def patternRowChangeVelocity(trackId, pitchindex, delta): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Row Velocity"), descriptionString="Change Row Velocity") for note in track.pattern.getRow(pitchindex): new = note["velocity"] + delta @@ -1204,6 +1233,7 @@ def setScaleToKeyword(trackId, keyword): This function is called not often and does not need to be performant. """ track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.scale[:]: setScale(trId, v, callback=True), descriptionString="Set Scale") rememberRootNote = track.pattern.scale[-1] #The last note has a special role by convention. No matter if this is the lowest midi-pitch or not. Most of the time it is the lowest though. @@ -1248,6 +1278,7 @@ def setScaleToKeyword(trackId, keyword): def changePatternVelocity(trackId, steps): track = session.data.trackById(trackId) + if not track: return session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Pattern Velocity"), descriptionString="Change Pattern Velocity") for note in track.pattern.data: new = note["velocity"] + steps @@ -1272,6 +1303,7 @@ def resizePatternWithoutScale(trackId, steps): return track = session.data.trackById(trackId) + if not track: return #We could use setScale for undo. But this requires a different set of callbacks. We use our own function, eventhough some of the calculations are not needed for undo. @@ -1305,11 +1337,13 @@ def resizePatternWithoutScale(trackId, steps): #Other functions. These can't be template functions because they use a specific track and Patroneos row and scale system. def noteOn(trackId, row): track = session.data.trackById(trackId) + if not track: return midipitch = track.pattern.scale[row] cbox.send_midi_event(0x90+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction) def noteOff(trackId, row): track = session.data.trackById(trackId) + if not track: return midipitch = track.pattern.scale[row] cbox.send_midi_event(0x80+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction) diff --git a/template/engine/sequencer.py b/template/engine/sequencer.py index 2541da2..a68af4f 100644 --- a/template/engine/sequencer.py +++ b/template/engine/sequencer.py @@ -61,6 +61,7 @@ class Score(Data): self.tracks = [] #see docstring self.tempoMap = TempoMap(parentData = self) self._template_processAfterInit() + self._tracksFailedLookup = [] def _template_processAfterInit(self): #needs a different name because there is an inherited class with the same method. """Call this after either init or instanceFromSerializedData""" @@ -119,11 +120,21 @@ class Score(Data): except Exception as e: #No Jack Meta Data or Error with ports. logger.error(e) + def trackById(self, trackId:int): + """Returns a track or None, if not found""" for track in self.tracks: if trackId == id(track): return track - raise ValueError(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}") + else: + #Previously this crashed with a ValueError. However, after a rare bug that a gui widget focussed out because a track was deleted and then tried to send its value to the engine we realize that this lookup can gracefully return None. + #Nothing will break: Functions that are not aware yet, that None is an option will crash when they try to access None as a track object. For this case we present the following logger error: + if not trackId in self._tracksFailedLookup: + logger.error(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}") + self._tracksFailedLookup.append(trackId) #prevent multiple error messages for the same track in a row. + return None + #raise ValueError(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}") + #Save / Load / Export