From f040b411b01966e5b2c9e7aff7e617b725009c05 Mon Sep 17 00:00:00 2001 From: Nils <> Date: Sun, 7 Apr 2019 10:20:29 +0200 Subject: [PATCH] WIP state for tempoTrack undo/redo before change to lazy undo --- engine/api.py | 142 +++-- engine/ccsubtrack.py | 680 +++++++++++++++++++++ engine/items.py | 19 +- engine/main.py | 11 +- engine/{graphtracks.py => tempotrack.py} | 743 ++--------------------- engine/track.py | 16 +- qtgui/conductor.py | 62 +- qtgui/graphs.py | 2 +- qtgui/mainwindow.py | 5 +- qtgui/timeline.py.delete | 499 --------------- template | 2 +- 11 files changed, 911 insertions(+), 1270 deletions(-) create mode 100644 engine/ccsubtrack.py rename engine/{graphtracks.py => tempotrack.py} (51%) delete mode 100644 qtgui/timeline.py.delete diff --git a/engine/api.py b/engine/api.py index 5ecba50..4ea091f 100644 --- a/engine/api.py +++ b/engine/api.py @@ -38,7 +38,8 @@ from template.helper import flatList, EndlessGenerator #Our Modules from . import items from . import lilypond -from .graphtracks import TempoItem +from .tempotrack import TempoItem, TempoTrack +from .ccsubtrack import GraphItem from .track import Track apiModuleSelfReference = sys.modules[__name__] @@ -1979,8 +1980,13 @@ def mergeWithNextGraphBlock(graphBlockId): #Tempo Track -#TODO: no undo here for various reasons. Many functions auto-delete hidden or overlapping tempo items with no way of currently remembering them. -#history.py says "Better no history than a wrong history." so we clear it, for now. +def _changeTempoTrackBlockAndItemOrder(listWithBlocksData): + """A helper function for that makes it possible to undo/redo properly. It registers + itself with complementary data as undo/redo.""" + orderBeforeInsert = session.data.tempoTrack.putBlockAndItemOrder(listWithBlocksData) + session.history.register(lambda o=orderBeforeInsert: _changeTempoTrackBlockAndItemOrder(o), descriptionString="Tempo Change") + callbacks._updateTempoTrack() + callbacks._historyChanged() def addTempoItem(blockId, positionInTicksRelativeToBlock, unitsPerMinute, referenceTicks, graphType = "standalone"): """blockId includes the track as well as the CC""" @@ -1990,17 +1996,23 @@ def addTempoItem(blockId, positionInTicksRelativeToBlock, unitsPerMinute, refere return tempoItem def _addExistingTempoItem(blockId, positionInTicksRelativeToBlock, tempoItem): - tempoBlock = session.data.tempoTrack.tempoBlockById(blockId) - tempoBlock.insert(tempoItem, positionInTicksRelativeToBlock) - session.history.clear() #TODO: overwriting a tempo item does not remember the old one + orderBeforeInsert = session.data.tempoTrack.getBlockAndItemOrder() + + tempoBlock = session.data.tempoTrack.tempoBlockById(blockId) + tempoBlock.insert(tempoItem, positionInTicksRelativeToBlock) + + session.history.register(lambda o=orderBeforeInsert: _changeTempoTrackBlockAndItemOrder(o), descriptionString="Tempo Change") callbacks._updateTempoTrack() + callbacks._historyChanged() def removeTempoItem(tempoItemId): + orderBeforeInsert = session.data.tempoTrack.getBlockAndItemOrder() tempoBlock, tempoItem = session.data.tempoTrack.tempoItemById(tempoItemId) tickPositionRelativeToBlockStart = tempoBlock.find(tempoItem) tempoBlock.remove(tickPositionRelativeToBlockStart) - session.history.clear() # no redo (add item) + session.history.register(lambda o=orderBeforeInsert: _changeTempoTrackBlockAndItemOrder(o), descriptionString="Delete Tempo Change") callbacks._updateTempoTrack() + callbacks._historyChanged() def moveTempoItem(tempoItemId, tickPositionAbsolute): """Figures out the target block automatically""" @@ -2016,71 +2028,115 @@ def removeCurrentTempoItem(): removeTempoItem(id(tempoItem)) #undo and callback def changeTempoBlockDuration(tempoBlockId, newDurationInTicks): - tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) - oldDuration = tempoBlock.duration - tempoBlock.duration = newDurationInTicks - session.history.clear() #resizing to a position of a tempo item in the previous block deletes that tempo item (otherwise there would be 2 items in the same spot). This is not remembered. - callbacks._updateTempoTrack() - -def appendTempoBlock(): - newBlock = session.data.tempoTrack.appendTempoBlock() - session.history.clear() + """Can do circular undo/redo""" + tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) + oldDuration = tempoBlock.duration + session.history.register(lambda i=tempoBlockId, o=oldDuration: changeTempoBlockDuration(i, o), descriptionString="Tempo Block Duration") + callbacks._historyChanged() + tempoBlock.duration = newDurationInTicks callbacks._updateTempoTrack() def rearrangeTempoBlocks(listOfBlockIds): - session.history.clear() + """Can do circular undo/redo. Is used by appendTempoBlock and others""" + oldOrder = session.data.tempoTrack.asListOfBlockIds() + session.history.register(lambda o=oldOrder: rearrangeTempoBlocks(o), descriptionString="Move Tempo Block") + callbacks._historyChanged() + session.data.tempoTrack.rearrangeBlocks(listOfBlockIds) callbacks._updateTempoTrack() -def changeTempoBlock(tempoBlockId, newParametersDict): - tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) - session.history.clear() - tempoBlock.putDataFromDict(newParametersDict) +def appendTempoBlock(): + oldOrder = session.data.tempoTrack.asListOfBlockIds() + session.history.register(lambda o=oldOrder: rearrangeTempoBlocks(o), descriptionString="Append Tempo Block") + callbacks._historyChanged() + + newBlock = session.data.tempoTrack.appendTempoBlock() callbacks._updateTempoTrack() def duplicateTempoBlock(tempoBlockId, times = 1): - tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) - session.history.clear() + oldOrder = session.data.tempoTrack.asListOfBlockIds() + session.history.register(lambda o=oldOrder: rearrangeTempoBlocks(o), descriptionString="Duplicate Tempo Block") + callbacks._historyChanged() + + tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) for i in range(times): session.data.tempoTrack.duplicateBlock(tempoBlock) callbacks._updateTempoTrack() def duplicateContentLinkTempoBlock(tempoBlockId, times = 1): + """This is also create content link""" + oldOrder = session.data.tempoTrack.asListOfBlockIds() + session.history.register(lambda o=oldOrder: rearrangeTempoBlocks(o), descriptionString="Duplicate Content Link Tempo Block") + callbacks._historyChanged() + tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) - session.history.clear() + for i in range(times): session.data.tempoTrack.duplicateContentLinkBlock(tempoBlock) callbacks._updateTempoTrack() -def unlinkTempoBlock(tempoBlockId): + +def changeTempoBlock(tempoBlockId, newParametersDict): + """Can do circular undo/redo""" tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) - newData, newDuration = tempoBlock.getUnlinkedData() - assert newData, newData - assert newDuration, newDuration - _setTempoBlockData(tempoBlock, newData, newDuration) #handles undo and callbacks + + oldParameters = tempoBlock.getDataAsDict() + session.history.register(lambda i=tempoBlockId, o=oldParameters: changeTempoBlock(i, o), descriptionString="Change Tempo Block") + callbacks._historyChanged() + + tempoBlock.putDataFromDict(newParametersDict) + callbacks._updateTempoTrack() -def _setTempoBlockData(tempoBlock, newData, newDuration): - """For undo and redo. - newDuration is the original list with a single integer that may be content linked. - Thats why we get and set the block._duration parameter directly instead of using the setter - .duration.""" - session.history.register(lambda bl=tempoBlock, old=tempoBlock.data, dur=tempoBlock._duration: _setTempoBlockData(bl, old, dur), descriptionString = "set tempo black data") - tempoBlock.data = newData - tempoBlock._duration = newDuration +def unlinkTempoBlock(tempoBlockId): + tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) + if len(tempoBlock.linkedContentBlocks) == 1: + return #This is not a content link block + + newData, newLinkedContentBlocks, newDuration = tempoBlock.getUnlinkedData() #does not set itself, just returns + _exchangeTempoBlockData(tempoBlock, newData, newLinkedContentBlocks, newDuration) #handles undo and callbacks - #no callbacks needed. +def _exchangeTempoBlockData(tempoBlock, newData, newLinkedContentBlocks, newDuration): + oldData = tempoBlock.data.copy() #shallow copy. otherwise we can't mix unlink with putBlockAndItemOrder + oldLinkedContentBlocks = tempoBlock.linkedContentBlocks + oldDuration = tempoBlock._duration[:] #shallow copy. + + undofunction = lambda: _exchangeTempoBlockData(tempoBlock, oldData, oldLinkedContentBlocks, oldDuration) #flipped around + session.history.register(undofunction, descriptionString="Unlink Tempo Block") + + tempoBlock.data = newData + tempoBlock.linkedContentBlocks = newLinkedContentBlocks + tempoBlock._duration = newDuration #mutable list of length 1 + + for bl in tempoBlock.linkedContentBlocks: + bl.data = newData + bl.linkedContentBlocks = newLinkedContentBlocks + bl._duration = newDuration #mutable list of length 1 + + callbacks._historyChanged() + callbacks._updateTempoTrack() + +def _lazyTempoTrackUndoRedo(new): + old = session.data.tempoTrack.serialize() + session.data.tempoTrack = TempoTrack.instanceFromSerializedData(new, parentData=session.data) + session.history.register(lambda d=old: _lazyTempoTrackUndoRedo(d), descriptionString="Change Tempo Track") + callbacks._historyChanged() + callbacks._updateTempoTrack() def splitTempoBlock(tempoBlockId, positionInTicksRelativeToBlock:int): - """tick position is relative to block start""" + """tick position is relative to block start""" + old = session.data.tempoTrack.serialize() success = session.data.tempoTrack.splitTempoBlock(tempoBlockId, positionInTicksRelativeToBlock) - if success: - session.history.clear() + if success: + session.history.register(lambda d=old: _lazyTempoTrackUndoRedo(d), descriptionString="Split Tempo Block") callbacks._updateTempoTrack() def mergeWithNextTempoBlock(tempoBlockId): + old = session.data.tempoTrack.serialize() positionForSplit = session.data.tempoTrack.mergeWithNextTempoBlock(tempoBlockId) - session.history.clear() #TODO: merging deletes hidden items in the first block without remembering them for undo - callbacks._updateTempoTrack() + if positionForSplit: + session.history.register(lambda d=old: _lazyTempoTrackUndoRedo(d), descriptionString="Join Tempo Block") + callbacks._historyChanged() + callbacks._updateTempoTrack() def deleteTempoBlock(tempoBlockId): tempoBlock = session.data.tempoTrack.tempoBlockById(tempoBlockId) diff --git a/engine/ccsubtrack.py b/engine/ccsubtrack.py new file mode 100644 index 0000000..538beb6 --- /dev/null +++ b/engine/ccsubtrack.py @@ -0,0 +1,680 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), +more specifically its template base application. + +Laborejo2 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import logging; logging.info("import {}".format(__file__)) + +#Standard Library Modules +from weakref import WeakValueDictionary, WeakSet + +#Third Party Modules +from calfbox import cbox + +#Template Modules +import template.engine.duration as duration +from template.engine.duration import D1, D4, D1024 +from template.helper import pairwise + +#Our modules + +class GraphItem(object): + """We use 'CC' here as synonym for whatever int value between 0 + and 127, typical 7bit midi values""" + + def __init__(self, ccStart): + self.ccStart = ccStart #int + self.graphType = "linear" #options: linear, standalone + self._secondInit(parentBlock = None) + self.lilypondParameters = {} + + def _secondInit(self, parentBlock): + """see Score._secondInit""" + #ignore parentBlock + pass + + @classmethod + def instanceFromSerializedData(cls, serializedObject, parentObject): + """see Score.instanceFromSerializedData""" + assert cls.__name__ == serializedObject["class"] + self = cls.__new__(cls) + self.ccStart = int(serializedObject["ccStart"]) + self.graphType = serializedObject["graphType"] + self.lilypondParameters = serializedObject["lilypondParameters"] + self._secondInit(parentBlock = parentObject) + return self + + def serialize(self): + result = {} + result["class"] = self.__class__.__name__ + result["ccStart"] = self.ccStart + result["graphType"] = self.graphType + result["lilypondParameters"] = self.lilypondParameters + return result + + def copy(self): + new = GraphItem(self.ccStart) + new.graphType = self.graphType + new.lilypondParameters = self.lilypondParameters.copy() + return new + + def linearRepresentation(self, ccEnd, tickStart, tickEnd): + """ + the variable is taken from the standard formula: f(x) = m*x + n + m = (x2-x1) / (y2-y1) + x1 = tick start + y1 = CC start + x2 = tick end + y2 = CC end + + tickStart and tickEnd are absolute values for the complete track + This means we can directly export them to calfbox. + + result is tuples (ccValue, tickPOsitionOfThatCCValue) + """ + + assert 0 <= self.ccStart < 128 + assert 0 <= ccEnd < 128 + + result = [] + if ccEnd == self.ccStart: #there is no interpolation. It is just one value. + result.append((self.ccStart, tickStart)) + return result + + m = (tickEnd - tickStart) / (ccEnd - self.ccStart) #we need to calculate this after making sure that ccend and start are not the same. Else we get /0 + #From here on: We actually need interpolation. Let the math begin. + if ccEnd > self.ccStart: #upward slope + iteratorList = list(range(self.ccStart, ccEnd)) #20 to 70 results in 20....69 + else: #downward slope + iteratorList = list(range(ccEnd+1, self.ccStart+1)) #70 to 20 results ins 70...21 + iteratorList.reverse() + + #Calculate at which tick a given ccValue will happen. value = m * (i - y1) + for ccValue in iteratorList: + result.append((ccValue, tickStart + int(m * (ccValue - self.ccStart)))) # int(m * (ccValue - self.ccStart)) results in 0 for the first item (set by the user). so the first item is just tickStart. that does not mean tickStart for the first item is 0. We just normalize to tickStart as virtual 0 here. + assert result + assert result[0][1] == tickStart + return result + + def staticRepresentation(self, ccEnd, tickStart, tickEnd): + if self.graphType == "standalone" or tickEnd < 0 or ccEnd < 0: + result = [(self.ccStart, tickStart)] + elif self.graphType == "linear": + result = self.linearRepresentation(ccEnd, tickStart, tickEnd) + else: + raise ValueError("Graph Type unknown:", self.graphType) + assert result + return result + +class GraphBlock(object): + """Basically a variant of structures.Block, but not for Item type, + but for GraphItem. + + Relative to the blocks beginning tick position, handled by the + GraphTrack, in self.data there are: + key = tick position + value = GraphItem + + GraphBlocks have a fixed duration. The user has to align them + manually. + """ + + firstBlockWithNewContentDuringDeserializeToObject = dict() #this is not resetted anywhere since each load is a program start. + allBlocks = {} #key is the blockId, value is the Block. This is a one way dict. It gets never deleted so undo can recover old blocks. Since blocks are unique this is no problem. + + def __init__(self, parentGraphTrack): + + self.data = {0:GraphItem(0)} #content linked, mutable. + self.name = str(id(self)) + self._duration = [D1*4] #len is always 1. Duration is content linked, thats why it is a mutable list of len==1. + #self.duration = 0 #triggers the setter. in reality this is set to a standard minimum value in def duration + self.linkedContentBlocks = WeakSet() #only new standalone blocks use this empty WeakSet. once you contentLink a block it will be overwritten. + self._secondInit(parentGraphTrack) + + def _secondInit(self, parentGraphTrack): + """see Score._secondInit""" + self.linkedContentBlocks.add(self) + self.rememberBlock() + self.parentGraphTrack = parentGraphTrack + + @property + def duration(self): + return self._duration[0] + + @duration.setter + def duration(self, newValue): + """Keep the mutable list at all cost""" + + if newValue <= 0: + raise ValueError("duration must be > 1") + listId = id(self._duration) + self._duration.pop() + self._duration.append(newValue) + assert len(self._duration) == 1 + assert listId == id(self._duration) + + def rememberBlock(self): + oid = id(self) + GraphBlock.allBlocks[oid] = self #This is on the score level, or a global level. That means we don't need to change this, even if the track gets moved to a new track by the api. + #weakref_finalize(self, print, "deleted block "+str(oid)) + return oid + + @classmethod + def instanceFromSerializedData(cls, serializedObject, parentObject): + """see Score.instanceFromSerializedData""" + assert cls.__name__ == serializedObject["class"] + self = cls.__new__(cls) + self.parentGraphTrack = parentObject + + if serializedObject["data"] is None: + firstBlock = GraphBlock.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] #first block with the same contentGroup. This is the one with the real data. + self.data = firstBlock.data + self._duration = firstBlock._duration + self.linkedContentBlocks = firstBlock.linkedContentBlocks #add self to this is in _secondInit + else: #Standalone or First occurence of a content linked block + self.data = {int(position):GraphItem.instanceFromSerializedData(item, parentObject = self) for position, item in serializedObject["data"].items()} + GraphBlock.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] = self + self.linkedContentBlocks = WeakSet() + self._duration = [int(serializedObject["duration"])] #this is just an int, minimumBlockInTicks or so. Not Item.Duration(). For save and load we drop the list as mutable value. + + self.name = serializedObject["name"] + self._secondInit(parentObject) + return self + + def serialize(self): + result = {} + result["class"] = self.__class__.__name__ + result["name"] = self.name + result["duration"] = self.duration #this is just an int, minimumBlockInTicks or so. Not Item.Duration(). For save and load we drop the list as mutable value. + + #We only save the data if this is the first content-linked block in a sequence. + contentLinkGroupId = id(self.data) + result["contentLinkGroup"] = contentLinkGroupId + + for block in self.parentGraphTrack.blocks: + blockId = id(block) + dataId = id(block.data) + if dataId == contentLinkGroupId: + if blockId == id(self): #first block with this dataId found. + result["data"] = {int(itemTickPosition):item.serialize() for itemTickPosition, item in self.data.items()} + else: #content linked, but not the first. Block already serialized. + result["data"] = None #we don't need to do anything more. The rest is handled by load and instanceFromSerializedData + break + else: + raise StopIteration("self block not found in graphTrack.blocks") + #loop ran through. This never happens. + + return result + + def getDataAsDict(self): + return { "name" : self.name, + "duration" : self.duration, + } + + def putDataFromDict(self, dataDict): + """modify inplace. Useful for a gui function. Compatible with + the data from getDataAsDict""" + self.name = dataDict["name"] + self.duration = dataDict["duration"] + + def copy(self): + """Return an independet copy of this block.""" + new = type(self)(parentGraphTrack = self.parentGraphTrack) + + assert len(new.linkedContentBlocks) == 1 #even a copy of a linked block becomes a stand-alone copy + + for itemTickPosition, item in self.data.items(): + new.data[itemTickPosition] = item.copy() + if self.name.endswith("-copy"): + new.name = self.name + else: + new.name = self.name + "-copy" + new._duration = self._duration[:] #mutable + return new + + def contentLink(self): + """Return a copy where only certain parameters + like Content are linked. Others can be changed""" + new = type(self)(parentGraphTrack = self.parentGraphTrack) + new.linkedContentBlocks = self.linkedContentBlocks + new.linkedContentBlocks.add(new) + new.data = self.data #mutable + new.name = self.name + new._duration = self._duration #mutable + return new + + def getUnlinkedData(self): + """Set and handled for undo/redo by the api. + This function does not unlink itself but needs + the api. Instead we return new independent data.""" + newData = {} + linkedContentBlocks = WeakSet() + linkedContentBlocks.add(self) + + for itemTickPosition, item in self.data.items(): #deep copy + copy = item.copy() + newData[itemTickPosition] = copy + + return newData, linkedContentBlocks, self._duration[:] #mutable. the api uses the list directly as well because we want to undo/restore the old original list, which may be content linked. + + def linkedContentBlocksInScore(self): + """Named for compatibility with structures block. + Added a safety net to make sure only blocks which are actually in the timeline will be + returned. Not those in the undo buffer. + + The return is sorted to be in the actual track order. + """ + assert len(self.linkedContentBlocks) >= 1 + blocksInTrack = [block for block in self.linkedContentBlocks if block.parentGraphTrack] + ordered = sorted(blocksInTrack, key = lambda block: self.parentGraphTrack.blocks.index(block)) + return ordered + + def insert(self, graphItem, tickPositionRelativeToBlockStart): + self.data[tickPositionRelativeToBlockStart] = graphItem + return True #cannot fail, for now. the api still waits for a positive return code + + def find(self, graphItem): + """find a relative tick position by graphItem. + We already know that the graphItem is in this block. Now we + need to find the position.""" + assert graphItem in self.data.values() + for position, item in self.data.items(): + if item is graphItem: + return position + else: + raise ValueError("graphItem not found in this block", graphItem, self) + + def remove(self, tickPositionRelativeToBlockStart): + if not tickPositionRelativeToBlockStart == 0: #don't allow the first item in a block to be deleted. Since the 0 item can't be moved this will always be the 0 item. + assert tickPositionRelativeToBlockStart != min(self.data.keys()) #this can't be the first item in this block. + del self.data[tickPositionRelativeToBlockStart] + return True + return False + + def move(self, tickPositionRelativeToBlockStart, newPosition): + """we don't allow the first item to be moved. Makes + things easier and clearer for the user""" + if tickPositionRelativeToBlockStart > 0 and not tickPositionRelativeToBlockStart == newPosition: #otherwise it would just delete the complete item because old and new are the same and there would be no duplicate item to delete. + self.data[newPosition] = self.data[tickPositionRelativeToBlockStart] + del self.data[tickPositionRelativeToBlockStart] + + def deleteHiddenItems(self): + if self.getMaxContentPosition() > self.duration: + toDelete = [pos for pos in self.data.keys() if pos > self.duration] #must be list, not generator, because we need the positions in advance to delete them later. A generator would delete from the dict while it is still generated. + for delPos in toDelete: + del self.data[delPos] + + def exportsAllItems(self): + """Does the current self.duration prevent GraphItems from + getting exported?""" + if max(self.data) > self.duration: + return False + else: + return True + + def getMaxContentPosition(self): + value = max(sorted(self.data.keys())) + return value + + def staticRepresentation(self): + """list of (tickPositionRelativeToBlockStart, GraphItem) tuples. + Only exports items which fit into this blocks duration.""" + sortedListOfTuples = [] + for tickPosition, graphItem in self.data.items(): + if tickPosition <= self.duration: + sortedListOfTuples.append((tickPosition, graphItem)) + sortedListOfTuples.sort() + return sortedListOfTuples + + def extendToTrackLength(self, myParentCCTrack): + """This is a user-called command intended for the last block + in a track + + Why not extend the last block automatically? + First these are too many updates so performance goes down. + Second it confuses the user when he/she attempts to split + or append. Especially append. + """ + assert self is myParentCCTrack.blocks[-1] + + plainScoreDuration = myParentCCTrack.score.duration() + durationWithoutLastBlock = myParentCCTrack.durationWithoutLastBlock() + #print ("score:", plainScoreDuration, "allOther", TempoBlock.tempoTrack.durationWithoutLastBlock()) + + if plainScoreDuration > 0 and plainScoreDuration > durationWithoutLastBlock: + self.duration = plainScoreDuration - durationWithoutLastBlock + +class GraphTrackCC(object): + """A track for midi Control Changes. + There is no cursor and no state. Just a sequence of blocks. + The actual CC value comes from the track.""" + def __init__(self, cc, parentTrack): + firstBlock = GraphBlock(parentGraphTrack = self) + self.blocks = [firstBlock] #there is always at least one block. + self.cc = cc + self._secondInit(parentTrack = parentTrack) + + def _secondInit(self, parentTrack): + """see Score._secondInit""" + self.parentTrack = parentTrack + + @classmethod + def instanceFromSerializedData(cls, serializedObject, parentObject): + """see Score.instanceFromSerializedData""" + assert cls.__name__ == serializedObject["class"] + self = cls.__new__(cls) + self.cc = int(serializedObject["cc"]) + self.blocks = [GraphBlock.instanceFromSerializedData(block, parentObject = self) for block in serializedObject["blocks"]] + self._secondInit(parentTrack = parentObject) + return self + + def serialize(self): + result = {} + result["class"] = self.__class__.__name__ + result["cc"] = self.cc + result["blocks"] = [block.serialize() for block in self.blocks] + return result + + def durationWithoutLastBlock(self): + result = 0 + for block in self.blocks[:-1]: #returns empty list when only one block in self.blocks. So no loop will happen and this function returns 0 + result += block.duration + return result + + def asListOfBlockIds(self): + """Return an ordered list of block ids""" + return [id(block) for block in self.blocks] + + def appendGraphBlock(self): + """A simple method to add a new GraphBlock at the end of the + current track. Basically you can do the same with split and + resize, but this is much more easier.""" + new = GraphBlock(parentGraphTrack = self) + return self.appendExistingGraphBlock(new) + + def appendExistingGraphBlock(self, graphBlock): + self.blocks.append(graphBlock) + return graphBlock + + def splitGraphBlock(self, graphBlock, positionInTicksRelativeToBlock): + """The new block will be right of the original content. If the new block will be empty + or has no real start value the last prevailing value will be used + as the blocks start-point""" + #TODO: refactoring. This is bascially a copy of splitTempoBlock except the block is not an id here. And the name. + block = graphBlock + assert block.duration > positionInTicksRelativeToBlock + + hasPointAtZero = False + toDelete = [] + modelNewBlock = GraphBlock(parentGraphTrack = self) #will not be used directly, but instead content links will be created. + for pos, item in block.data.items(): + #Determine if a ccPoint gets to move to the new block + if pos >= positionInTicksRelativeToBlock: + if pos == positionInTicksRelativeToBlock: + hasPointAtZero = True + modelNewBlock.data[pos - positionInTicksRelativeToBlock] = item + toDelete.append(pos) + #else: ccPoint stays in the old block + + for pos in toDelete: + del block.data[pos] + + #Since every block comes with a value at position 0 we need to check if this was already replaced or if we need to adjust it to the real tempo at this point in time. + if not hasPointAtZero: + realStartCCPoint = block.data[block.getMaxContentPosition()] #only the remaining items. + modelNewBlock.data[0] = realStartCCPoint.copy() + + #Now the original block and all its content links have fewer items than before + #For every of the content-linked blocks in the tempo track we need to create a new block and insert it right next to it. + + for bl in block.linkedContentBlocksInScore(): + index = self.blocks.index(bl) + link = modelNewBlock.contentLink() + self.blocks.insert(index +1, link) + + #duration is mutable. All content links change duration as well. + link.duration = block.duration - positionInTicksRelativeToBlock + block.duration = positionInTicksRelativeToBlock + + del GraphBlock.allBlocks[id(modelNewBlock)] #Clean up the model block since it was never intended to go into the TempoTrack + + return True + + def mergeWithNextGraphBlock(self, graphBlock): + """see structures block (the music block) for an explanation about merging content linked + blocks. + + Hidden items (after the current duration value) of the original block + will be deleted. Hidden items of the follow-up block will be merged, but stay hidden. + """ + #TODO: refactoring. This is bascially a copy of splitTempoBlock except the block is not an id here. And the name. + + block = graphBlock + + if len(self.blocks) == 1: + #print ("only one block in the track") + return False + + blockIndex = self.blocks.index(block) + + if blockIndex+1 == len(self.blocks): + #print ("not for the last block") + return False + + nextIndex = blockIndex + 1 + nextBlock = self.blocks[nextIndex] + + firstBlock_endingValue = block.data[block.getMaxContentPosition()].ccStart + nextBlock_startingValue = nextBlock.data[0].ccStart + + startDurationToReturnForUndo = block.duration + + if len(block.linkedContentBlocksInScore()) == 1 and len(nextBlock.linkedContentBlocksInScore()) == 1: #both blocks are standalone. no content-links. This also prevents that both blocks are content links of each other. + block.deleteHiddenItems() + + if firstBlock_endingValue == nextBlock_startingValue: #remove redundancy + del nextBlock.data[0] + + for pos, item in nextBlock.data.items(): + assert not pos + block.duration in block.data + block.data[pos + block.duration] = item + + block.duration += nextBlock.duration + self.deleteBlock(nextBlock) + + return startDurationToReturnForUndo + + elif len(block.linkedContentBlocksInScore()) == len(nextBlock.linkedContentBlocksInScore()): #It is maybe possible to build pairs from the current block and all its content links with the follow up block and all its content links. + #Further testing required: + for firstBlock, secondBlock in zip(block.linkedContentBlocksInScore(), nextBlock.linkedContentBlocksInScore()): + if firstBlock.data is secondBlock.data: #content link of itself in succession. Very common usecase, but not compatible with merging. + #print ("content link follows itself") + return False + elif not self.blocks.index(firstBlock) + 1 == self.blocks.index(secondBlock): #all first blocks must be followed directly by a content link of the second block. linkedContentBlocksInScore() returns a blocklist in order so we can compare. + #print ("not all blocks-pairs are next to each other") + return False + + #Test complete without exit. All blocks can be paired up. + #print ("Test complete. Merging begins") + + block.deleteHiddenItems() + + if firstBlock_endingValue == nextBlock_startingValue: #remove redundancy + del nextBlock.data[0] + + for pos, item in nextBlock.data.items(): + assert not pos + block.duration in block.data #this includes pos==0, if not deleted above. + block.data[pos + block.duration] = item + + def deleteBlock(self, graphBlock): + """at least one block. If you want to delete the track + use api.deleteGraphTrackCC""" + if len(self.blocks) > 1: + graphBlock.parentGraphTrack = None + self.blocks.remove(graphBlock) + return graphBlock + + def duplicateBlock(self, graphBlock): + index = self.blocks.index(graphBlock) + copy = graphBlock.copy() + self.blocks.insert(index +1, copy) + + def duplicateContentLinkBlock(self, graphBlock): + index = self.blocks.index(graphBlock) + linked = graphBlock.contentLink() + self.blocks.insert(index +1, linked) + + def blocksAsDict(self): + """Key is the block id, value the block instance""" + result = {} + for block in self.blocks: + result[id(block)] = block + return result + + def rearrangeBlocks(self, listOfBlockIds): + """Reorder the blocks in this track. + Achtung! Not including a block will delete this block + This is not allowed so we check for it.""" + #blocksDict = self.blocksAsDict() + newBlockArrangement = [] + + for idLong in listOfBlockIds: + #newBlockArrangement.append(blocksDict[idLong]) + newBlockArrangement.append(GraphBlock.allBlocks[idLong]) #all blocks includes deleted blocks in the undo memory. + + #a block is a unique unit in Laborejo2. Let's make sure there are no duplicates. + #http://stackoverflow.com/questions/480214/how-do-you-remove-duplicates-from-a-list-in-python-whilst-preserving-order + seen = set() + seen_add = seen.add + self.blocks = [ x for x in newBlockArrangement if x not in seen and not seen_add(x)] + assert self.blocks + + def staticRepresentation(self): + typeString = "" + sumOfBlocksDurationsWithoutCurrent = 0 + result = [] + patternBlob = bytes() # Create a binary blob that contains the MIDI events for CC tracks + self.cleanBlockEdges() + for blockIndex in range(len(self.blocks)): + block = self.blocks[blockIndex] + assert len(block.data) > 0 + l = block.staticRepresentation() #this takes care that only user items which fit in the block.duration are exported. + for itemIndex in range(len(l)): #range starts from 0 so we can use this as index. + #l has tuples (tickPositionFromBlockStart, GraphItem) + thisPosition = l[itemIndex][0] + thisGraphItem = l[itemIndex][1] + + #Check if we reached the last item in this block. + if itemIndex is len(l)-1: #len counts from 1, itemIndex from 0. + assert thisGraphItem is l[-1][1] #l[-1] is a tuple (tickPositionFromBlockStart, GraphItem) + #Is there another block after this one? + if block is self.blocks[-1]: #this was the last block? + #there is no nextGraphItem and subsequently no interpolation. + typeString = "lastInTrack" # also a switch for later. + nextPosition = -1 #doesn't matter + #this is checked later in the loop, before we create the exportDict + else: #there is still a block left after the current + #The nextGraphItem can be found in the next block. + nextBlock = self.blocks[blockIndex+1] + nextBlockL = nextBlock.staticRepresentation() + nextPosition = nextBlockL[0][0] + block.duration #Instead of itemIndex we just need [0] for the first in the next block. + nextGraphItem = nextBlockL[0][1] #Instead of itemIndex we just need [0] for the first + else: #default case. Next item is still in the same block. + nextPosition = l[itemIndex+1][0] + nextGraphItem = l[itemIndex+1][1] + + #We now generate a chain of items from the current position to the next, + #at least one, depending on the interpolation type (like linear, none etc.) + + if typeString == "lastInTrack": + userItemAndInterpolatedItemsPositions = thisGraphItem.staticRepresentation(-1, thisPosition, -1) #-1 are magic markers that indicate a forced standalone mode. no interpolation, we just get one item back. + else: + assert thisPosition >= 0 + assert nextPosition >= 0 + typeString = "user" #interpolated, lastInTrack + userItemAndInterpolatedItemsPositions = thisGraphItem.staticRepresentation(nextGraphItem.ccStart, thisPosition, nextPosition) + + for ccValue, generatedTickPosition in userItemAndInterpolatedItemsPositions: #generatedTickPosition is relative to the block beginning. + if typeString == "user" or typeString == "lastInTrack": #interpolated items can be anywhere. We don't care much about them. + assert generatedTickPosition <= block.duration + + assert 127 >= ccValue >= 0 + assert generatedTickPosition >= 0 + + exportDictItem = { + "type" : typeString, + "value": -1*ccValue, #minus goes up because it reduces the line position, which starts from top of the screen. For a tempo this is a bpm value for quarter notes. + "position" : sumOfBlocksDurationsWithoutCurrent + generatedTickPosition, #generatedTickPosition is relative to the block beginning. + "id" : id(thisGraphItem), + "blockId": id(block), + "minPossibleAbsolutePosition" : sumOfBlocksDurationsWithoutCurrent, #If you want to move this item to the left or right, this is only possible within the current block. + "maxPossibleAbsolutePosition" : sumOfBlocksDurationsWithoutCurrent + block.duration, #If you want to move this item to the left or right, this is only possible within the current block. + "lastInBlock" : len(block.data) == 1, + } + result.append(exportDictItem) + typeString = "interpolated" #The next items in userItemAndInterpolatedItemsPositions are interpolated items. Reset once we leave the local forLoop. + + #numbers from 0-15 which represent the midi channels all CCs are sent to. Only replaced by a new tuple by the user directly. + if self.parentTrack.ccChannels: + for channel in self.parentTrack.ccChannels: + blob = cbox.Pattern.serialize_event(exportDictItem["position"], 0xB0 + channel, self.cc, ccValue) #position, status byte+channel, controller number, controller value #TODO use channel of the parent note track at that moment in time. + patternBlob += blob + else: #If empty then CC uses the tracks initial midi channel. + blob = cbox.Pattern.serialize_event(exportDictItem["position"], 0xB0 + self.parentTrack.initialMidiChannel, self.cc, ccValue) #position, status byte+channel, controller number, controller value #TODO use channel of the parent note track at that moment in time. + patternBlob += blob + + #Prepare data for the next block. + sumOfBlocksDurationsWithoutCurrent += block.duration #the naming is only true until the last iteration. Then all blocks, even the current one, are in the sum and can be used below. + + + sumOfBlockDurations = sumOfBlocksDurationsWithoutCurrent #Choose the correct name. Explicit is better than implicit. + #if sumOfBlockDurations > 0: + self.parentTrack.sequencerInterface.setSubtrack(key=self.cc, blobs=[(patternBlob, 0, sumOfBlockDurations),]) #(bytes-blob, position, length) + return result + + def staticGraphBlocksRepresentation(self): + """Return a sorted list""" + result = [] + tickCounter = 0 + for block in self.blocks: + result.append({"type" : "GraphBlock", "id":id(block), "name":block.name, "duration":block.duration, "position":tickCounter, "exportsAllItems":block.exportsAllItems()}) + tickCounter += block.duration + return result + + def staticTrackRepresentation(self): + result = {} + return result + + def graphItemById(self, graphItemId): + for graphBlock in self.blocks: + for tickPosition, graphItem in graphBlock.staticRepresentation(): # a list of tuples + if id(graphItem) == graphItemId: + return graphBlock, graphItem + else: + raise ValueError("graphItemId not found in this track", graphItemId) + + def cleanBlockEdges(self): + """If a first block has an item at tick 300 and a second block is resized to begin at + tick 300 as well then the last item and the start-item are in the same spot. + The last item needs to go away.""" + for block in self.blocks: + if block.getMaxContentPosition() == block.duration: + block.remove(block.duration) + diff --git a/engine/items.py b/engine/items.py index 3246a55..6d72d9f 100644 --- a/engine/items.py +++ b/engine/items.py @@ -715,10 +715,10 @@ class Item(object): export basis. For example for GUI frontends. They don't have to parse and calculate their own values in slow pure Python then - midiBytes is a list with pblob/binary data generated by + midiBytes is a list with patternBlob/binary data generated by cbox.Pattern.serialize_event(position, midibyte1 (noteon), midibyte2(pitch), midibyte3(velocity)) Items with duration, like chords, will most like need to create - at least two pblobs, one for the start (e.g. note on) and one + at least two patternBlobs, one for the start (e.g. note on) and one for the stop (e.g. note off) """ raise NotImplementedError("Implement _exportObject() for this item type!") @@ -887,10 +887,10 @@ class TemplateItem(Item): export basis. For example for GUI frontends. They don't have to parse and calculate their own values in slow pure Python then - midiBytes is a list with pblob/binary data generated by + midiBytes is a list with patternBlob/binary data generated by cbox.Pattern.serialize_event(position, midibyte1 (noteon), midibyte2(pitch), midibyte3(velocity)) Items with duration, like chords, will most like need to create - at least two pblobs, one for the start (e.g. note on) and one + at least two patternBlobs, one for the start (e.g. note on) and one for the stop (e.g. note off) """ raise NotImplementedError("Implement _exportObject() for this item type!") @@ -2669,6 +2669,13 @@ class InstrumentChange(Item): "UIstring" : "{}[pr{}{}{}]".format(_nameStr, self.program, _msbStr, _lsbStr), #this is for a UI, possibly a text UI, maybe for simple items of a GUI. Make it as short and unambigious as possible. } + #hier weiter machen. die midiBytes können so exportiert werden, wahrscheinlich auch auf diesem channel. + #Aber die müssen im Track dann am Ende rausgefiltert werden und auf einen eigenen cbox midi track. + #Die Alternative wäre das aufzuteilen. Eins auf CC0, eins auf CC32 und der Program Change auf einen eigenen Kanal? + #Was ist mit sowas wie initial program? Wo wird das geändert? In den Track Properties. Vielleichst ist das selbst ein InstrumentChange Objekt. Könnte eins sein zumindest. + + + def _lilypond(self): """called by block.lilypond(), returns a string. Don't create white-spaces yourself, this is done by the structures. @@ -2794,10 +2801,10 @@ class RecordedNote(Item): export basis. For example for GUI frontends. They don't have to parse and calculate their own values in slow pure Python then - midiBytes is a list with pblob/binary data generated by + midiBytes is a list with patternBlob/binary data generated by cbox.Pattern.serialize_event(position, midibyte1 (noteon), midibyte2(pitch), midibyte3(velocity)) Items with duration, like chords, will most like need to create - at least two pblobs, one for the start (e.g. note on) and one + at least two patternBlobs, one for the start (e.g. note on) and one for the stop (e.g. note off) """ raise NotImplementedError("Implement _exportObject() for this item type!") diff --git a/engine/main.py b/engine/main.py index a1fb9bf..f14e13c 100644 --- a/engine/main.py +++ b/engine/main.py @@ -34,8 +34,7 @@ import template.engine.sequencer from .block import Block from .track import Track from .cursor import Cursor -from. graphtracks import TempoTrack - +from .tempotrack import TempoTrack class Data(template.engine.sequencer.Score): @@ -261,11 +260,8 @@ class Data(template.engine.sequencer.Score): self.currentTrack().goToTickindex(nowInTicks) def cursorExport(self): - try: - return self.cursor.exportObject(self.currentTrack().state) - except ValueError: #debugging without callbacks - warn("cursor export did not work. If this was during debugging without callbacks it is allright.") - return {} + return self.cursor.exportObject(self.currentTrack().state) + def _cursorExportTrackTick(self, track, tickPosition): originalPosition = track.state.position() @@ -737,7 +733,6 @@ class Data(template.engine.sequencer.Score): putBlockAndItemOrder. It looks for true changes itself and returns a list of trackIDs that need callback updates. - No blocks will be deleted or replaced with this because deleteSelection leaves empty blocks and paste is not capable of creating new blocks. diff --git a/engine/graphtracks.py b/engine/tempotrack.py similarity index 51% rename from engine/graphtracks.py rename to engine/tempotrack.py index 9911ac6..68c7401 100644 --- a/engine/graphtracks.py +++ b/engine/tempotrack.py @@ -28,670 +28,11 @@ from weakref import WeakValueDictionary, WeakSet #Third Party Modules #Template Modules -import template.engine.duration as duration from template.engine.duration import D1, D4, D1024 -from template.helper import pairwise #Our modules +from .ccsubtrack import GraphTrackCC, GraphBlock -class GraphItem(object): - """We use 'CC' here as synonym for whatever int value between 0 - and 127, typical 7bit midi values""" - - def __init__(self, ccStart): - self.ccStart = ccStart #int - self.graphType = "linear" #options: linear, standalone - self._secondInit(parentBlock = None) - self.lilypondParameters = {} - - def _secondInit(self, parentBlock): - """see Score._secondInit""" - #ignore parentBlock - pass - - @classmethod - def instanceFromSerializedData(cls, serializedObject, parentObject): - """see Score.instanceFromSerializedData""" - assert cls.__name__ == serializedObject["class"] - self = cls.__new__(cls) - self.ccStart = int(serializedObject["ccStart"]) - self.graphType = serializedObject["graphType"] - self.lilypondParameters = serializedObject["lilypondParameters"] - self._secondInit(parentBlock = parentObject) - return self - - def serialize(self): - result = {} - result["class"] = self.__class__.__name__ - result["ccStart"] = self.ccStart - result["graphType"] = self.graphType - result["lilypondParameters"] = self.lilypondParameters - return result - - def copy(self): - new = GraphItem(self.ccStart) - new.graphType = self.graphType - new.lilypondParameters = self.lilypondParameters.copy() - return new - - def linearRepresentation(self, ccEnd, tickStart, tickEnd): - """ - the variable is taken from the standard formula: f(x) = m*x + n - m = (x2-x1) / (y2-y1) - x1 = tick start - y1 = CC start - x2 = tick end - y2 = CC end - - tickStart and tickEnd are absolute values for the complete track - This means we can directly export them to calfbox. - - result is tuples (ccValue, tickPOsitionOfThatCCValue) - """ - - assert 0 <= self.ccStart < 128 - assert 0 <= ccEnd < 128 - - result = [] - if ccEnd == self.ccStart: #there is no interpolation. It is just one value. - result.append((self.ccStart, tickStart)) - return result - - m = (tickEnd - tickStart) / (ccEnd - self.ccStart) #we need to calculate this after making sure that ccend and start are not the same. Else we get /0 - #From here on: We actually need interpolation. Let the math begin. - if ccEnd > self.ccStart: #upward slope - iteratorList = list(range(self.ccStart, ccEnd)) #20 to 70 results in 20....69 - else: #downward slope - iteratorList = list(range(ccEnd+1, self.ccStart+1)) #70 to 20 results ins 70...21 - iteratorList.reverse() - - #Calculate at which tick a given ccValue will happen. value = m * (i - y1) - for ccValue in iteratorList: - result.append((ccValue, tickStart + int(m * (ccValue - self.ccStart)))) # int(m * (ccValue - self.ccStart)) results in 0 for the first item (set by the user). so the first item is just tickStart. that does not mean tickStart for the first item is 0. We just normalize to tickStart as virtual 0 here. - assert result - assert result[0][1] == tickStart - return result - - def staticRepresentation(self, ccEnd, tickStart, tickEnd): - if self.graphType == "standalone" or tickEnd < 0 or ccEnd < 0: - result = [(self.ccStart, tickStart)] - elif self.graphType == "linear": - result = self.linearRepresentation(ccEnd, tickStart, tickEnd) - else: - raise ValueError("Graph Type unknown:", self.graphType) - assert result - return result - -class GraphBlock(object): - """Basically a variant of structures.Block, but not for Item type, - but for GraphItem. - - Relative to the blocks beginning tick position, handled by the - GraphTrack, in self.data there are: - key = tick position - value = GraphItem - - GraphBlocks have a fixed duration. The user has to align them - manually. - """ - - firstBlockWithNewContentDuringDeserializeToObject = dict() #this is not resetted anywhere since each load is a program start. - allBlocks = {} #key is the blockId, value is the Block. This is a one way dict. It gets never deleted so undo can recover old blocks. Since blocks are unique this is no problem. - - def __init__(self, parentGraphTrack): - - self.data = {0:GraphItem(0)} #content linked, mutable. - self.name = str(id(self)) - self._duration = [D1*4] #len is always 1. Duration is content linked, thats why it is a mutable list of len==1. - #self.duration = 0 #triggers the setter. in reality this is set to a standard minimum value in def duration - self.linkedContentBlocks = WeakSet() #only new standalone blocks use this empty WeakSet. once you contentLink a block it will be overwritten. - self._secondInit(parentGraphTrack) - - def _secondInit(self, parentGraphTrack): - """see Score._secondInit""" - self.linkedContentBlocks.add(self) - self.rememberBlock() - self.parentGraphTrack = parentGraphTrack - - @property - def duration(self): - return self._duration[0] - - @duration.setter - def duration(self, newValue): - """Keep the mutable list at all cost""" - - if newValue <= 0: - raise ValueError("duration must be > 1") - listId = id(self._duration) - self._duration.pop() - self._duration.append(newValue) - assert len(self._duration) == 1 - assert listId == id(self._duration) - - def rememberBlock(self): - oid = id(self) - GraphBlock.allBlocks[oid] = self #This is on the score level, or a global level. That means we don't need to change this, even if the track gets moved to a new track by the api. - #weakref_finalize(self, print, "deleted block "+str(oid)) - return oid - - @classmethod - def instanceFromSerializedData(cls, serializedObject, parentObject): - """see Score.instanceFromSerializedData""" - assert cls.__name__ == serializedObject["class"] - self = cls.__new__(cls) - self.parentGraphTrack = parentObject - - if serializedObject["data"] is None: - firstBlock = GraphBlock.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] #first block with the same contentGroup. This is the one with the real data. - self.data = firstBlock.data - self._duration = firstBlock._duration - self.linkedContentBlocks = firstBlock.linkedContentBlocks #add self to this is in _secondInit - else: #Standalone or First occurence of a content linked block - self.data = {int(position):GraphItem.instanceFromSerializedData(item, parentObject = self) for position, item in serializedObject["data"].items()} - GraphBlock.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] = self - self.linkedContentBlocks = WeakSet() - self._duration = [int(serializedObject["duration"])] #this is just an int, minimumBlockInTicks or so. Not Item.Duration(). For save and load we drop the list as mutable value. - - self.name = serializedObject["name"] - self._secondInit(parentObject) - return self - - def serialize(self): - result = {} - result["class"] = self.__class__.__name__ - result["name"] = self.name - result["duration"] = self.duration #this is just an int, minimumBlockInTicks or so. Not Item.Duration(). For save and load we drop the list as mutable value. - - #We only save the data if this is the first content-linked block in a sequence. - contentLinkGroupId = id(self.data) - result["contentLinkGroup"] = contentLinkGroupId - - for block in self.parentGraphTrack.blocks: - blockId = id(block) - dataId = id(block.data) - if dataId == contentLinkGroupId: - if blockId == id(self): #first block with this dataId found. - result["data"] = {int(itemTickPosition):item.serialize() for itemTickPosition, item in self.data.items()} - else: #content linked, but not the first. Block already serialized. - result["data"] = None #we don't need to do anything more. The rest is handled by load and instanceFromSerializedData - break - else: - raise StopIteration("self block not found in graphTrack.blocks") - #loop ran through. This never happens. - - return result - - def getDataAsDict(self): - return { "name" : self.name, - "duration" : self.duration, - } - - def putDataFromDict(self, dataDict): - """modify inplace. Useful for a gui function. Compatible with - the data from getDataAsDict""" - self.name = dataDict["name"] - self.duration = dataDict["duration"] - - def copy(self): - """Return an independet copy of this block.""" - new = type(self)(parentGraphTrack = self.parentGraphTrack) - - assert len(new.linkedContentBlocks) == 1 #even a copy of a linked block becomes a stand-alone copy - - for itemTickPosition, item in self.data.items(): - new.data[itemTickPosition] = item.copy() - if self.name.endswith("-copy"): - new.name = self.name - else: - new.name = self.name + "-copy" - new._duration = self._duration[:] #mutable - return new - - def contentLink(self): - """Return a copy where only certain parameters - like Content are linked. Others can be changed""" - new = type(self)(parentGraphTrack = self.parentGraphTrack) - new.linkedContentBlocks = self.linkedContentBlocks - new.linkedContentBlocks.add(new) - new.data = self.data - new.name = self.name - new._duration = self._duration #mutable - return new - - def getUnlinkedData(self): - """Set and handled for undo/redo by the api""" - newData = {} - linkedContentBlocks = WeakSet() - linkedContentBlocks.add(self) - - for itemTickPosition, item in self.data.items(): - copy = item.copy() - newData[itemTickPosition] = copy - copy.linkedContentBlocks = linkedContentBlocks - - return newData, self._duration[:] #mutable. the api uses the list directly as well because we want to undo/restore the old original list, which may be content linked. - - def linkedContentBlocksInScore(self): - """Named for compatibility with structures block. - Added a safety net to make sure only blocks which are actually in the timeline will be - returned. Not those in the undo buffer. - - The return is sorted to be in the actual track order. - """ - assert len(self.linkedContentBlocks) >= 1 - blocksInTrack = [block for block in self.linkedContentBlocks if block.parentGraphTrack] - ordered = sorted(blocksInTrack, key = lambda block: self.parentGraphTrack.blocks.index(block)) - return ordered - - def insert(self, graphItem, tickPositionRelativeToBlockStart): - self.data[tickPositionRelativeToBlockStart] = graphItem - return True #cannot fail, for now. the api still waits for a positive return code - - def find(self, graphItem): - """find a relative tick position by graphItem. - We already know that the graphItem is in this block. Now we - need to find the position.""" - assert graphItem in self.data.values() - for position, item in self.data.items(): - if item is graphItem: - return position - else: - raise ValueError("graphItem not found in this block", graphItem, self) - - def remove(self, tickPositionRelativeToBlockStart): - if not tickPositionRelativeToBlockStart == 0: #don't allow the first item in a block to be deleted. Since the 0 item can't be moved this will always be the 0 item. - assert tickPositionRelativeToBlockStart != min(self.data.keys()) #this can't be the first item in this block. - del self.data[tickPositionRelativeToBlockStart] - return True - return False - - def move(self, tickPositionRelativeToBlockStart, newPosition): - """we don't allow the first item to be moved. Makes - things easier and clearer for the user""" - if tickPositionRelativeToBlockStart > 0 and not tickPositionRelativeToBlockStart == newPosition: #otherwise it would just delete the complete item because old and new are the same and there would be no duplicate item to delete. - self.data[newPosition] = self.data[tickPositionRelativeToBlockStart] - del self.data[tickPositionRelativeToBlockStart] - - def deleteHiddenItems(self): - if self.getMaxContentPosition() > self.duration: - toDelete = [pos for pos in self.data.keys() if pos > self.duration] #must be list, not generator, because we need the positions in advance to delete them later. A generator would delete from the dict while it is still generated. - for delPos in toDelete: - del self.data[delPos] - - def exportsAllItems(self): - """Does the current self.duration prevent GraphItems from - getting exported?""" - if max(self.data) > self.duration: - return False - else: - return True - - def getMaxContentPosition(self): - value = max(sorted(self.data.keys())) - return value - - def staticRepresentation(self): - """list of (tickPositionRelativeToBlockStart, GraphItem) tuples. - Only exports items which fit into this blocks duration.""" - sortedListOfTuples = [] - for tickPosition, graphItem in self.data.items(): - if tickPosition <= self.duration: - sortedListOfTuples.append((tickPosition, graphItem)) - sortedListOfTuples.sort() - return sortedListOfTuples - - def extendToTrackLength(self, myParentCCTrack): - """This is a user-called command intended for the last block - in a track - - Why not extend the last block automatically? - First these are too many updates so performance goes down. - Second it confuses the user when he/she attempts to split - or append. Especially append. - """ - assert self is myParentCCTrack.blocks[-1] - - plainScoreDuration = myParentCCTrack.score.duration() - durationWithoutLastBlock = myParentCCTrack.durationWithoutLastBlock() - #print ("score:", plainScoreDuration, "allOther", TempoBlock.tempoTrack.durationWithoutLastBlock()) - - if plainScoreDuration > 0 and plainScoreDuration > durationWithoutLastBlock: - self.duration = plainScoreDuration - durationWithoutLastBlock - -class GraphTrackCC(object): - """A track for midi Control Changes. - There is no cursor and no state. Just a sequence of blocks. - The actual CC value comes from the track.""" - def __init__(self, cc, parentTrack): - firstBlock = GraphBlock(parentGraphTrack = self) - self.blocks = [firstBlock] #there is always at least one block. - self.cc = cc - self._secondInit(parentTrack = parentTrack) - - def _secondInit(self, parentTrack): - """see Score._secondInit""" - self.parentTrack = parentTrack - self.score = parentTrack.parentData - - @classmethod - def instanceFromSerializedData(cls, serializedObject, parentObject): - """see Score.instanceFromSerializedData""" - assert cls.__name__ == serializedObject["class"] - self = cls.__new__(cls) - self.cc = int(serializedObject["cc"]) - self.blocks = [GraphBlock.instanceFromSerializedData(block, parentObject = self) for block in serializedObject["blocks"]] - self._secondInit(parentTrack = parentObject) - return self - - def serialize(self): - result = {} - result["class"] = self.__class__.__name__ - result["cc"] = self.cc - result["blocks"] = [block.serialize() for block in self.blocks] - return result - - def durationWithoutLastBlock(self): - result = 0 - for block in self.blocks[:-1]: #returns empty list when only one block in self.blocks. So no loop will happen and this function returns 0 - result += block.duration - return result - - def asListOfBlockIds(self): - """Return an ordered list of block ids""" - result = [] - for block in self.blocks: - result.append(id(block)) - return result - - def appendGraphBlock(self): - """A simple method to add a new GraphBlock at the end of the - current track. Basically you can do the same with split and - resize, but this is much more easier.""" - new = GraphBlock(parentGraphTrack = self) - return self.appendExistingGraphBlock(new) - - def appendExistingGraphBlock(self, graphBlock): - self.blocks.append(graphBlock) - return graphBlock - - def splitGraphBlock(self, graphBlock, positionInTicksRelativeToBlock): - """The new block will be right of the original content. If the new block will be empty - or has no real start value the last prevailing value will be used - as the blocks start-point""" - #TODO: refactoring. This is bascially a copy of splitTempoBlock except the block is not an id here. And the name. - block = graphBlock - assert block.duration > positionInTicksRelativeToBlock - - hasPointAtZero = False - toDelete = [] - modelNewBlock = GraphBlock(parentGraphTrack = self) #will not be used directly, but instead content links will be created. - for pos, item in block.data.items(): - #Determine if a ccPoint gets to move to the new block - if pos >= positionInTicksRelativeToBlock: - if pos == positionInTicksRelativeToBlock: - hasPointAtZero = True - modelNewBlock.data[pos - positionInTicksRelativeToBlock] = item - toDelete.append(pos) - #else: ccPoint stays in the old block - - for pos in toDelete: - del block.data[pos] - - #Since every block comes with a value at position 0 we need to check if this was already replaced or if we need to adjust it to the real tempo at this point in time. - if not hasPointAtZero: - realStartCCPoint = block.data[block.getMaxContentPosition()] #only the remaining items. - modelNewBlock.data[0] = realStartCCPoint.copy() - - #Now the original block and all its content links have fewer items than before - #For every of the content-linked blocks in the tempo track we need to create a new block and insert it right next to it. - - for bl in block.linkedContentBlocksInScore(): - index = self.blocks.index(bl) - link = modelNewBlock.contentLink() - self.blocks.insert(index +1, link) - - #duration is mutable. All content links change duration as well. - link.duration = block.duration - positionInTicksRelativeToBlock - block.duration = positionInTicksRelativeToBlock - - del GraphBlock.allBlocks[id(modelNewBlock)] #Clean up the model block since it was never intended to go into the TempoTrack - - return True - - def mergeWithNextGraphBlock(self, graphBlock): - """see structures block (the music block) for an explanation about merging content linked - blocks. - - Hidden items (after the current duration value) of the original block - will be deleted. Hidden items of the follow-up block will be merged, but stay hidden. - """ - #TODO: refactoring. This is bascially a copy of splitTempoBlock except the block is not an id here. And the name. - - block = graphBlock - - if len(self.blocks) == 1: - #print ("only one block in the track") - return False - - blockIndex = self.blocks.index(block) - - if blockIndex+1 == len(self.blocks): - #print ("not for the last block") - return False - - nextIndex = blockIndex + 1 - nextBlock = self.blocks[nextIndex] - - firstBlock_endingValue = block.data[block.getMaxContentPosition()].ccStart - nextBlock_startingValue = nextBlock.data[0].ccStart - - startDurationToReturnForUndo = block.duration - - if len(block.linkedContentBlocksInScore()) == 1 and len(nextBlock.linkedContentBlocksInScore()) == 1: #both blocks are standalone. no content-links. This also prevents that both blocks are content links of each other. - block.deleteHiddenItems() - - if firstBlock_endingValue == nextBlock_startingValue: #remove redundancy - del nextBlock.data[0] - - for pos, item in nextBlock.data.items(): - assert not pos + block.duration in block.data - block.data[pos + block.duration] = item - - block.duration += nextBlock.duration - self.deleteBlock(nextBlock) - - return startDurationToReturnForUndo - - elif len(block.linkedContentBlocksInScore()) == len(nextBlock.linkedContentBlocksInScore()): #It is maybe possible to build pairs from the current block and all its content links with the follow up block and all its content links. - #Further testing required: - for firstBlock, secondBlock in zip(block.linkedContentBlocksInScore(), nextBlock.linkedContentBlocksInScore()): - if firstBlock.data is secondBlock.data: #content link of itself in succession. Very common usecase, but not compatible with merging. - #print ("content link follows itself") - return False - elif not self.blocks.index(firstBlock) + 1 == self.blocks.index(secondBlock): #all first blocks must be followed directly by a content link of the second block. linkedContentBlocksInScore() returns a blocklist in order so we can compare. - #print ("not all blocks-pairs are next to each other") - return False - - #Test complete without exit. All blocks can be paired up. - #print ("Test complete. Merging begins") - - block.deleteHiddenItems() - - if firstBlock_endingValue == nextBlock_startingValue: #remove redundancy - del nextBlock.data[0] - - for pos, item in nextBlock.data.items(): - assert not pos + block.duration in block.data #this includes pos==0, if not deleted above. - block.data[pos + block.duration] = item - - - def deleteBlock(self, graphBlock): - """at least one block. If you want to delete the track - use api.deleteGraphTrackCC""" - if len(self.blocks) > 1: - graphBlock.parentGraphTrack = None - self.blocks.remove(graphBlock) - return graphBlock - - def duplicateBlock(self, graphBlock): - index = self.blocks.index(graphBlock) - copy = graphBlock.copy() - self.blocks.insert(index +1, copy) - - def duplicateContentLinkBlock(self, graphBlock): - index = self.blocks.index(graphBlock) - linked = graphBlock.contentLink() - self.blocks.insert(index +1, linked) - - def blocksAsDict(self): - """Key is the block id, value the block instance""" - result = {} - for block in self.blocks: - result[id(block)] = block - return result - - def rearrangeBlocks(self, listOfBlockIds): - """Reorder the blocks in this track. - Achtung! Not including a block will delete this block - This is not allowed so we check for it.""" - #blocksDict = self.blocksAsDict() - newBlockArrangement = [] - - for idLong in listOfBlockIds: - #newBlockArrangement.append(blocksDict[idLong]) - newBlockArrangement.append(GraphBlock.allBlocks[idLong]) #all blocks includes deleted blocks in the undo memory. - - #a block is a unique unit in Laborejo2. Let's make sure there are no duplicates. - #http://stackoverflow.com/questions/480214/how-do-you-remove-duplicates-from-a-list-in-python-whilst-preserving-order - seen = set() - seen_add = seen.add - self.blocks = [ x for x in newBlockArrangement if x not in seen and not seen_add(x)] - assert self.blocks - - def staticRepresentation(self): - typeString = "" - sumOfBlocksDurationsWithoutCurrent = 0 - result = [] - pblob = bytes() # Create a binary blob that contains the MIDI events for CC tracks - self.cleanBlockEdges() - for blockIndex in range(len(self.blocks)): - block = self.blocks[blockIndex] - assert len(block.data) > 0 - l = block.staticRepresentation() #this takes care that only user items which fit in the block.duration are exported. - for itemIndex in range(len(l)): #range starts from 0 so we can use this as index. - #l has tuples (tickPositionFromBlockStart, GraphItem) - thisPosition = l[itemIndex][0] - thisGraphItem = l[itemIndex][1] - - #Check if we reached the last item in this block. - if itemIndex is len(l)-1: #len counts from 1, itemIndex from 0. - assert thisGraphItem is l[-1][1] #l[-1] is a tuple (tickPositionFromBlockStart, GraphItem) - #Is there another block after this one? - if block is self.blocks[-1]: #this was the last block? - #there is no nextGraphItem and subsequently no interpolation. - typeString = "lastInTrack" # also a switch for later. - nextPosition = -1 #doesn't matter - #this is checked later in the loop, before we create the exportDict - else: #there is still a block left after the current - #The nextGraphItem can be found in the next block. - nextBlock = self.blocks[blockIndex+1] - nextBlockL = nextBlock.staticRepresentation() - nextPosition = nextBlockL[0][0] + block.duration #Instead of itemIndex we just need [0] for the first in the next block. - nextGraphItem = nextBlockL[0][1] #Instead of itemIndex we just need [0] for the first - else: #default case. Next item is still in the same block. - nextPosition = l[itemIndex+1][0] - nextGraphItem = l[itemIndex+1][1] - - #We now generate a chain of items from the current position to the next, - #at least one, depending on the interpolation type (like linear, none etc.) - - if typeString == "lastInTrack": - userItemAndInterpolatedItemsPositions = thisGraphItem.staticRepresentation(-1, thisPosition, -1) #-1 are magic markers that indicate a forced standalone mode. no interpolation, we just get one item back. - else: - assert thisPosition >= 0 - assert nextPosition >= 0 - typeString = "user" #interpolated, lastInTrack - userItemAndInterpolatedItemsPositions = thisGraphItem.staticRepresentation(nextGraphItem.ccStart, thisPosition, nextPosition) - - for ccValue, generatedTickPosition in userItemAndInterpolatedItemsPositions: #generatedTickPosition is relative to the block beginning. - if typeString == "user" or typeString == "lastInTrack": #interpolated items can be anywhere. We don't care much about them. - assert generatedTickPosition <= block.duration - - assert 127 >= ccValue >= 0 - assert generatedTickPosition >= 0 - - exportDictItem = { - "type" : typeString, - "value": -1*ccValue, #minus goes up because it reduces the line position, which starts from top of the screen. For a tempo this is a bpm value for quarter notes. - "position" : sumOfBlocksDurationsWithoutCurrent + generatedTickPosition, #generatedTickPosition is relative to the block beginning. - "id" : id(thisGraphItem), - "blockId": id(block), - "minPossibleAbsolutePosition" : sumOfBlocksDurationsWithoutCurrent, #If you want to move this item to the left or right, this is only possible within the current block. - "maxPossibleAbsolutePosition" : sumOfBlocksDurationsWithoutCurrent + block.duration, #If you want to move this item to the left or right, this is only possible within the current block. - "lastInBlock" : len(block.data) == 1, - } - result.append(exportDictItem) - typeString = "interpolated" #The next items in userItemAndInterpolatedItemsPositions are interpolated items. Reset once we leave the local forLoop. - - if self.parentTrack.ccChannels: - for channel in self.parentTrack.ccChannels: - blob = cbox.Pattern.serialize_event(exportDictItem["position"], 0xB0 + channel, self.cc, ccValue) #position, status byte+channel, controller number, controller value #TODO use channel of the parent note track at that moment in time. - pblob += blob - else: - blob = cbox.Pattern.serialize_event(exportDictItem["position"], 0xB0 + self.parentTrack.initialMidiChannel, self.cc, ccValue) #position, status byte+channel, controller number, controller value #TODO use channel of the parent note track at that moment in time. - pblob += blob - - - #Prepare data for the next block. - sumOfBlocksDurationsWithoutCurrent += block.duration #the naming is only true until the last iteration. Then all blocks, even the current one, are in the sum and can be used below. - - if sumOfBlocksDurationsWithoutCurrent > 0: - if self.calfboxTheWholeTrackAsPattern: - self.calfboxTheWholeTrackAsPattern[0].delete() - self.calfboxTheWholeTrackAsPattern = None - self.calfboxTheWholeTrackAsPattern = (cbox.Document.get_song().pattern_from_blob(pblob, sumOfBlocksDurationsWithoutCurrent), sumOfBlocksDurationsWithoutCurrent) #sumOfBlocksDurationsWithoutCurrent now even includes the current one. See above. - assert self.calfboxTheWholeTrackAsPattern - self.clearCalfboxTrack() #does NOT clear calfboxTheWholeTrackAsPattern - trackAsMidiData, trackPlainLengthInTicks = self.calfboxTheWholeTrackAsPattern - self.calfboxTrack.add_clip(0, 0, trackPlainLengthInTicks, trackAsMidiData) #pos, offset, length(and not end-position, but is the same), pattern - - self.score.session.playbackNeedsUpdate = True - #Before calfbox converts this python data into C data we need to call cbox.Document.get_song().update_playback(). This is done when playback starts and checks if self.score.session.playbackNeedsUpdate. - return result - - def staticGraphBlocksRepresentation(self): - """Return a sorted list""" - result = [] - tickCounter = 0 - for block in self.blocks: - result.append({"type" : "GraphBlock", "id":id(block), "name":block.name, "duration":block.duration, "position":tickCounter, "exportsAllItems":block.exportsAllItems()}) - tickCounter += block.duration - return result - - def staticTrackRepresentation(self): - result = {} - if type(self) is TempoTrack: - mini, maxi = self.getMinimumAndMaximumValues() - result["minimumAbsoluteTempoValue"] = mini - result["maximumAbsoluteTempoValue"] = maxi - return result - - def graphItemById(self, graphItemId): - for graphBlock in self.blocks: - for tickPosition, graphItem in graphBlock.staticRepresentation(): # a list of tuples - if id(graphItem) == graphItemId: - return graphBlock, graphItem - else: - raise ValueError("graphItemId not found in this track", graphItemId) - - def cleanBlockEdges(self): - """If a first block has an item at tick 300 and a second block is resized to begin at - tick 300 as well then the last item and the start-item are in the same spot. - The last item needs to go away.""" - for block in self.blocks: - if block.getMaxContentPosition() == block.duration: - block.remove(block.duration) class TempoItem(object): """A tempo Item exports to Lilypond. @@ -903,7 +244,9 @@ class TempoBlock(GraphBlock): class TempoTrack(GraphTrackCC): """The Tempo Track is Laborejos own independent structure that sends data to - template.sequencer.TempoMap which handles the actual midi/cbox tempo map """ + template.sequencer.TempoMap which handles the actual midi/cbox tempo map. + + There is only one TempoTrack instance in the main data strucutre. """ def __init__(self, parentData): firstBlock = TempoBlock(parentGraphTrack = self) @@ -1080,22 +423,23 @@ class TempoTrack(GraphTrackCC): def tempoBlockById(self, tempoBlockId): return TempoBlock.allTempoBlocks[tempoBlockId] - - def expandLastBlockToScoreDuration(self): - """This can lead to unexpected behaviour if the last block is content-linked and will resize + def _expandLastBlockToScoreDuration(self): + """ + The tempo tracks needs to be at least as long as the score. + We force it on every staticExport. + + This can lead to unexpected behaviour if the last block is content-linked and will resize automatically when the score-duration gets longer. All the other content links will resize as well. - This is expected behaviour and there is a note in the users manual to prevent that confusion""" - block = self.blocks[-1] + We therefore define it to "expected behaviour" and put a note in the users + manual to prevent that confusion""" + + lastBlock = self.blocks[-1] plainScoreDuration = self.parentData.duration() + duration = sum(block.duration for block in self.blocks) durationWithoutLastBlock = self.durationWithoutLastBlock() - if plainScoreDuration > 0:# and plainScoreDuration - durationWithoutLastBlock > block.duration: - block.duration = plainScoreDuration - durationWithoutLastBlock - return block.duration - else: - block.duration = D1*4#plainScoreDuration - durationWithoutLastBlock - return block.duration - + if plainScoreDuration > 0 and duration < plainScoreDuration: + lastBlock.duration = plainScoreDuration - durationWithoutLastBlock def tempoBlocKByAbsolutePosition(self, tickPositionAbsolute): """The staticBlockRepresentation is in correct, ascending, order. @@ -1158,12 +502,12 @@ class TempoTrack(GraphTrackCC): def staticRepresentation(self): """see structures.Track.staticRepresentation""" - self.expandLastBlockToScoreDuration() + self._expandLastBlockToScoreDuration() typeString = "" sumOfBlocksDurationsWithoutCurrent = 0 result = [] - pblob = bytes() # Create a binary blob that contains the MIDI events for CC tracks + patternBlob = bytes() # Create a binary blob that contains the MIDI events for CC tracks for blockIndex in range(len(self.blocks)): block = self.blocks[blockIndex] assert len(block.data) > 0 @@ -1296,7 +640,6 @@ class TempoTrack(GraphTrackCC): return result - def rearrangeBlocks(self, listOfBlockIds): """Reorder the blocks in this track. Achtung! Not including a block will delete this block.""" @@ -1314,7 +657,6 @@ class TempoTrack(GraphTrackCC): self.blocks = [ x for x in newBlockArrangement if x not in seen and not seen_add(x)] assert self.blocks - @property def factor(self): return self.parentData.tempoMap.factor @@ -1322,6 +664,53 @@ class TempoTrack(GraphTrackCC): def setFactor(self, factor:float): self.parentData.tempoMap.setFactor(factor) + def staticTrackRepresentation(self): + result = {} + mini, maxi = self.getMinimumAndMaximumValues() + result["minimumAbsoluteTempoValue"] = mini + result["maximumAbsoluteTempoValue"] = maxi + return result + + + def getBlockAndItemOrder(self)->list: + """see main.getBlockAndItemOrder + + We save the blocks data, not the blocks. Later in putBlockAndItemOrder we put the + data into the .data attribute. + + This is primarily meant for undo, both functions assume that they restore only one unit of + change distance. In particular this function will not restore block duration/size changes. + + Items get values only on creation. That means an edit will remove the old one and replace + with a new one. If we therefore create a shallow copy of the block dicts we can always + restore the deleted or modified states. + """ + result = [] + seen = {} # originalDataId : shallow copy. Takes care of content linked blocks data. + + for block in self.blocks: + blockDataId = id(block.data) + if not blockDataId in seen: + seen[blockDataId] = block.data.copy() #shallow dict copy. copied dict has different id than original. + result.append(seen[blockDataId]) + + return result + + + def putBlockAndItemOrder(self, listWithBlocksData:list): + """opposite of getBlockAndItemOrder. See there""" + """see getBlockAndItemOrder for documentation. + + Returns its own undo data""" + assert len(self.blocks) == len(listWithBlocksData) + orderBefore = self.getBlockAndItemOrder() + + for block, parameterBlockData in zip(self.blocks, listWithBlocksData): + block.data = parameterBlockData + + return orderBefore + + def lilypond(self): """Based on the static export""" def _ly(tempoItem, nextTempoItem): diff --git a/engine/track.py b/engine/track.py index 20c3fc0..d06d18b 100644 --- a/engine/track.py +++ b/engine/track.py @@ -33,7 +33,7 @@ import template.engine.sequencer #Our modules from .block import Block -from .graphtracks import GraphTrackCC +from .ccsubtrack import GraphTrackCC from .items import * #parseRight and trackState need all items. class DynamicSettingsSignature(object): @@ -376,7 +376,7 @@ class Track(object): self.parentData = parentData self.sequencerInterface = template.engine.sequencer.SequencerInterface(parentTrack=self, name=name) - self.ccGraphTracks = {} #cc number, graphTrackCC.d + self.ccGraphTracks = {} #cc number, graphTrackCC. Every CC is its own SequencerTrack that routes to this tracks jack midi out self.ccChannels = tuple() #numbers from 0-15 which represent the midi channels all CCs are sent to. Only replaced by a new tuple by the user directly. From json this becomes a list, so don't test for type. If empty then CC uses the initial midi channel. self.blocks = [Block(track = self)] @@ -979,7 +979,7 @@ class Track(object): #TODO: However, it is even less elegant to put this call in all rhythm editing methods and functions. inserts, block duplicate, content links, augment, tuplets undo etc. #Taken out an placed in tempo Export. #self.score.tempoTrack.expandLastBlockToScoreDuration() #we guarantee that the tempo track is always at least as long as the music tracks. - pblob = bytes() # Create a binary blob that contains the MIDI events + patternBlob = bytes() # Create a binary blob that contains the MIDI events originalPosition = self.state.position() self.getPreliminaryData() @@ -998,9 +998,9 @@ class Track(object): assert self.initialMidiBankMsb == initialProgamChange.msb assert self.initialMidiBankLsb == initialProgamChange.lsb if initialProgamChange.program >= 0: #-1 is off. - pblob += cbox.Pattern.serialize_event(0, 0xC0 + self.initialMidiChannel, initialProgamChange.program, 0) - pblob += cbox.Pattern.serialize_event(0, 0xB0 + self.initialMidiChannel, 0, self.initialMidiBankMsb) #position, status byte+channel, controller number, controller value - pblob += cbox.Pattern.serialize_event(0, 0xB0 + self.initialMidiChannel, 32, self.initialMidiBankLsb) #position, status byte+channel, controller number, controller value + patternBlob += cbox.Pattern.serialize_event(0, 0xC0 + self.initialMidiChannel, initialProgamChange.program, 0) + patternBlob += cbox.Pattern.serialize_event(0, 0xB0 + self.initialMidiChannel, 0, self.initialMidiBankMsb) #position, status byte+channel, controller number, controller value + patternBlob += cbox.Pattern.serialize_event(0, 0xB0 + self.initialMidiChannel, 32, self.initialMidiBankLsb) #position, status byte+channel, controller number, controller value localRight = self.right #performance resultAppend = result.append #performance @@ -1018,7 +1018,7 @@ class Track(object): resultAppend(expObj) dur = expObj["completeDuration"] for blob in expObj["midiBytes"]: #a list of - pblob += blob + patternBlob += blob if expObj["type"] == "Chord" or expObj["type"] == "Rest": #save for later when we create Beams. No other use. _allExportedChordsAppend(expObj) @@ -1148,7 +1148,7 @@ class Track(object): metaData["duration"] = self.state.tickindex #tickindex is now at the end, so this is the end duration. This includes Blocks minimumDuration as well since it is included in left/right metaData["beams"] = resultBeamGroups - t = (pblob, 0, self.state.tickindex) + t = (patternBlob, 0, self.state.tickindex) self.sequencerInterface.setTrack([t]) #(bytes-blob, position, length) #tickindex is still on the last position, which means the second parameter is the length self.toPosition(originalPosition, strict = False) #has head() in it diff --git a/qtgui/conductor.py b/qtgui/conductor.py index d1ec983..b2cb2bd 100644 --- a/qtgui/conductor.py +++ b/qtgui/conductor.py @@ -33,7 +33,8 @@ oneRectToReturnThemAll = QtCore.QRectF(0,0,0,0) #prevent the annoying "NotImplem _zValuesRelativeToConductor = { #Only use for objects added directly to the Conductor, not their children. "line":0, - "block":3, + "startItem":1, + "block":2, "item":4, "handle":5, } @@ -56,6 +57,10 @@ class Conductor(QtWidgets.QGraphicsItem): self.staffLine.setParentItem(self) self.staffLine.setPos(0,0) + #Displays the real time in minutes and seconds. Definition at the end of thils file. + self.timeLine = TimeLine(self) #registers its own callbacks + self.timeLine.setParentItem(self) + api.callbacks.updateTempoTrackBlocks.append(self.updateBlocks) api.callbacks.updateTempoTrack.append(self.createGraphicItemsFromData) api.callbacks.updateTempoTrackMeta.append(self.updateMetaData) @@ -93,8 +98,7 @@ class Conductor(QtWidgets.QGraphicsItem): for dictExportItem in staticRepresentationList: guiBlock = ConductorTransparentBlock(parent = self, staticExportItem = dictExportItem, x = 0, y = -10, w = dictExportItem["duration"] / constantsAndConfigs.ticksToPixelRatio, h = 20) guiBlock.setParentItem(self) - guiBlock.setPos(dictExportItem["position"] / constantsAndConfigs.ticksToPixelRatio,0) - guiBlock.setZValue(_zValuesRelativeToConductor["block"]) #including the handle + guiBlock.setPos(dictExportItem["position"] / constantsAndConfigs.ticksToPixelRatio,0) rightBorder = (dictExportItem["duration"] + dictExportItem["position"]) / constantsAndConfigs.ticksToPixelRatio self.updateStaffLine(rightBorder) @@ -115,6 +119,7 @@ class Conductor(QtWidgets.QGraphicsItem): self.staffLine.setZValue(_zValuesRelativeToConductor["line"]) def createGraphicItemsFromData(self, staticRepresentationList): + self.staticPoints = staticRepresentationList removeInstancesFromScene(TempoPoint) @@ -190,21 +195,20 @@ class ConductorTransparentBlock(QtWidgets.QGraphicsRectItem): marker = QtWidgets.QGraphicsLineItem(0, 0, 0, -10) #vertical marker to connect to the conductor line marker.setParentItem(self) - instances = [] def __init__(self, parent, staticExportItem, x, y, w, h): self.__class__.instances.append(self) super().__init__(x, y, w, h) self.setFlags(QtWidgets.QGraphicsItem.ItemDoesntPropagateOpacityToChildren|QtWidgets.QGraphicsItem.ItemIsMovable) #no mouseReleaseEvent without selection or movable. + self.setAcceptHoverEvents(True) self.parent = parent #Conductor instance self.color = stringToColor(staticExportItem["name"]) self.trans = QtGui.QColor("transparent") self.setPen(self.trans) - self.setBrush(self.color) - self.setOpacity(0.2) #mimic background behaviour + self.setBrush(self.color) + self.setOpacity(0.2) #mimic background behaviour self.staticExportItem = staticExportItem - self.posBeforeMove = None self.cursorPosOnMoveStart = None @@ -227,11 +231,14 @@ class ConductorTransparentBlock(QtWidgets.QGraphicsRectItem): self.startLabel = QtWidgets.QGraphicsSimpleTextItem("") self.endLabel = QtWidgets.QGraphicsSimpleTextItem("") - #Add End Line + #Add Resizing Handle at the end self.marker = ConductorBlockHandle(parent = self) self.marker.setParentItem(self) self.marker.setPos(staticExportItem["duration"] / constantsAndConfigs.ticksToPixelRatio, -1/2* self.rect().height()+2) + #self.setZValue(_zValuesRelativeToConductor["handle"]) #includes the handle + + #def paint(self, *args): # """Prevent the selection rectangle when clicking the item""" #!! This also prevents the rectangle to show up. Very bad decision. @@ -245,7 +252,7 @@ class ConductorTransparentBlock(QtWidgets.QGraphicsRectItem): self.marker.setX(self.marker.pos().x() * factor) def mouseMoveEvent(self, event): - """Don't use the qt system. we move ourselves""" + """Don't use the qt system. we move ourselves""" event.accept() def mousePressEvent(self, event): @@ -266,6 +273,7 @@ class ConductorTransparentBlock(QtWidgets.QGraphicsRectItem): #posView = self.parent.parentScore.parentView.mapFromGlobal(posGlobal) #a widget #posScene = self.parent.parentScore.parentView.mapToScene(posView) #print (posGlobal, posView, posScene, event.scenePos()) + if self.cursorPosOnMoveStart: self.setPos(event.scenePos().x(), self.posBeforeMove.y()) @@ -306,7 +314,6 @@ class ConductorTransparentBlock(QtWidgets.QGraphicsRectItem): posRelativeToBlockStart = round(posRelativeToBlockStart / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm if posRelativeToBlockStart > 0: - print (self.staticExportItem["id"], posRelativeToBlockStart, posRelativeToBlockStart/api.D1) api.splitTempoBlock(self.staticExportItem["id"], int(posRelativeToBlockStart)) def contextMenuEvent(self, event): @@ -325,7 +332,7 @@ class ConductorTransparentBlock(QtWidgets.QGraphicsRectItem): ] callContextMenu(listOfLabelsAndFunctions) -class ConductorBlockHandle(QtWidgets.QGraphicsLineItem): +class ConductorBlockHandle(QtWidgets.QGraphicsRectItem): """Provides user interaction so the temp block can be resized by moving this handle with the mouse left and right. When user interaction happens this handle acts upon its parent transparent block to resize it @@ -333,26 +340,27 @@ class ConductorBlockHandle(QtWidgets.QGraphicsLineItem): def __init__(self, parent): self.parentTransparentBlock = parent - super().__init__(0,-1, 0, parent.rect().height()-4) #x1, y1, x2, y2 - self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable|QtWidgets.QGraphicsItem.ItemClipsToShape) + #Line item super().__init__(0,-1, 0, parent.rect().height()-4) #x1, y1, x2, y2 + super().__init__(-3, -2, 3, parent.rect().height()) #x, y, w, h + self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable|QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) #QtWidgets.QGraphicsItem.ItemClipsToShape puts the item behind the parent rect and only receives event inside its own shape. self.setAcceptHoverEvents(True) self.setCursor(QtCore.Qt.SizeHorCursor) + self.setZValue(_zValuesRelativeToConductor["handle"]) #The handle does not compete with the transparent block but with its contents! Therefore the startItem is set to have a custom lower priority than the handle + self.setBrush(QtGui.QColor("black")) + + self.minimalSize = api.D1 / constantsAndConfigs.ticksToPixelRatio pen = QtGui.QPen() # creates a default pen - if not self.parentTransparentBlock.staticExportItem["exportsAllItems"]: - pen.setStyle(QtCore.Qt.DotLine) - pen.setWidth(2) + #if not self.parentTransparentBlock.staticExportItem["exportsAllItems"]: + # pen.setStyle(QtCore.Qt.DotLine) + pen.setWidth(0) self.setPen(pen) self.inactivePen = pen self.inactivePen.setColor(QtGui.QColor("black")) self.activePen = QtGui.QPen(pen) - self.activePen.setColor(QtGui.QColor("cyan")) - - self.minimalSize = api.D1 / constantsAndConfigs.ticksToPixelRatio - - self.setZValue(_zValuesRelativeToConductor["handle"]) + self.activePen.setColor(QtGui.QColor("cyan")) def shape(self): """Return a more accurate shape for this item so that @@ -361,14 +369,15 @@ class ConductorBlockHandle(QtWidgets.QGraphicsLineItem): path.addRect(QtCore.QRectF(-2, -2, 5, self.parentTransparentBlock.rect().height()+2 )) #this is directly related to inits parameter x, y, w, h return path - def hoverEnterEvent(self, event): - self.setPen(self.activePen) + def hoverEnterEvent(self, event): + self.setPen(self.activePen) #self.parentTransparentBlock.setBrush(self.parentTransparentBlock.color) - + self.setBrush(QtGui.QColor("cyan")) - def hoverLeaveEvent(self, event): + def hoverLeaveEvent(self, event): self.setPen(self.inactivePen) #self.parentTransparentBlock.setBrush(self.parentTransparentBlock.trans) + self.setBrush(QtGui.QColor("black")) def mousePressEvent(self, event): self.posBeforeMove = self.pos() @@ -432,7 +441,8 @@ class TempoPoint(QtWidgets.QGraphicsItem): self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemIsFocusable) #Too irritating. And confuses with handle movement. self.setCursor(QtCore.Qt.SizeHorCursor) #this sets the cursor while the mouse is over the item. It is independent of AcceptHoverEvents else: - self.ungrabMouse = api.nothing #to surpress a warning from the context menu + self.setZValue(0) #avoid hovering conflict with block handle + self.ungrabMouse = api.nothing #to surpress a warning from the context menu self.note = QtWidgets.QGraphicsTextItem("") self.note.setParentItem(self) diff --git a/qtgui/graphs.py b/qtgui/graphs.py index 5632bc6..1fc5da8 100644 --- a/qtgui/graphs.py +++ b/qtgui/graphs.py @@ -116,7 +116,7 @@ class CCPath(QtWidgets.QGraphicsRectItem): self.userItems.append(p) t = QtWidgets.QGraphicsSimpleTextItem(str(abs(point["value"]))) - t.setFont(constantsAndConfigs.theFont) + #t.setFont(constantsAndConfigs.theFont) t.setParentItem(self) t.setScale(0.75) self.other.append(t) diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index b250a8d..cfebae8 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -142,7 +142,10 @@ class MainWindow(TemplateMainWindow): def updateStatusBar(self, exportCursorDict): """Every cursor movement updates the statusBar message""" c = exportCursorDict - i = c["item"] + try: + i = c["item"] + except: + print (c) if i: ly = i.lilypond() if (not ly) or len(ly) > 10: diff --git a/qtgui/timeline.py.delete b/qtgui/timeline.py.delete deleted file mode 100644 index 46def60..0000000 --- a/qtgui/timeline.py.delete +++ /dev/null @@ -1,499 +0,0 @@ -#! /usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) - -This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), -more specifically its template base application. - -Laborejo2 is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -import logging; logging.info("import {}".format(__file__)) - - -from PyQt5 import QtCore, QtGui, QtSvg, QtWidgets -from .constantsAndConfigs import constantsAndConfigs -from template.qtgui.helper import stringToColor -import engine.api as api - - -"""This file handles the timeline which can be found at the top of the -notation editor. - -Actually this is a rhythm-line since our linear value is rhythm, or -ticks, which is the same. - -That just means that the space between a whole note is always the same -in pixel. Neither tempo nor any other value can change this (except -proportional zooming of course). -""" - -class TimeLine(QtWidgets.QGraphicsRectItem): - - def __init__(self, parentView): - - #The right border of the rect gets updated through self.updateGraphBlockTrack - #Achtung! if you set y to zero and then extend the height in the minus direction it will appear correctly but the hover events will be broken in WEIRD ways. weird means the hover-rect seems to be curved and you have different hover ranger on different X-coordinates. strange stuff... - super().__init__(0, 0, 1, 15) #(x, y, w, h) - - self.setAcceptHoverEvents(True) - self.setOpacity(0.75) - self.setFlag(QtWidgets.QGraphicsItem.ItemDoesntPropagateOpacityToChildren) - self.inactiveBrush = QtGui.QColor(233, 233, 233) #most of the time - self.activeBrush = QtGui.QColor(200, 200, 200) - self.setBrush(self.inactiveBrush) - self.tempoPoints = [] - self.blockEndMarkers = [] - self.appendGraphBlockButtonProxy = None #filled in by updateGraphBlockTrack because it adds a widget, which needs a scene which is not available during init - self.parentView = parentView - self.lastTouchedReferenceTicks = 210*2**8 #convenience. remember the last tempo unit so the user can just click on the timeline without entering a value each time. - - """ - #Pen for the borders - pen = QtGui.QPen() - pen.setCapStyle(QtCore.Qt.RoundCap) - pen.setJoinStyle(QtCore.Qt.RoundJoin) - pen.setWidth(1) - pen.setColor(QtGui.QColor("Red")) - self.setPen(pen) - """ - #self.setPen(QtGui.QColor("transparent")) - - api.callbacks.updateTempoTrack.append(self.createGraphicItemsFromData) - api.callbacks.updateTempoTrackBlocks.append(self.updateGraphBlockTrack) - - def hoverEnterEvent(self, event): - """TempoItems can be deleted by hovering over them and pressing - the delete key. We want to prevent that the delete key also - deletes notes. This will happen if the cursor does not exactly - hover over the tempopoint. To prevent this accident we - deactivate the scores delete key. - And show the user a different color to let them know.""" - self.setBrush(self.activeBrush) - self.scene().parentView.viewport().update() - #self.scene().parentView.mainWindow.ui.actionDelete.setEnabled(False) #disable Score-Item delete - self.scene().parentView.mainWindow.menuActionDatabase.ccEditMode() - event.accept() - - def hoverLeaveEvent(self, event): - """reverse hoverEnterEvent""" - self.setBrush(self.inactiveBrush) - self.scene().parentView.viewport().update() - #self.scene().parentView.mainWindow.ui.actionDelete.setEnabled(True) #enable Score-Item delete again - self.scene().parentView.mainWindow.menuActionDatabase.noteEditMode() - event.accept() - - def mousePressEvent(self, event): - if event.button() == 1: # QtCore.Qt.MouseButton.LeftButton - self.add(event.scenePos()) #create a new tempo point by telling the api a position and then reacting to "delete all, recreate" from the callback. - event.accept() - else: - super().mousePressEvent(event) #call default implementation from QGraphicsRectItem - - #self.parentView.verticalScrollBar().valueChanged.connect(self.repositionAfterScroll) - #self.parentView.horizontalScrollBar().valueChanged.connect(self.repositionAfterScroll) - #def repositionAfterScroll(self): - # Dont use it. Only in here as later template for handling scrollbars. - # - #print(self, self.parentView.mapToScene(0, 0).y()) - # #self.setY(self.parentView.mapToScene(0, 0).y()) - # #self.setX(self.parentView.mapToScene(0, 0).x()) - # self.outline.setX(self.parentView.mapToScene(0, 0).x()) - # self.setY(-1*constantsAndConfigs.trackHeight) - - def createAppendButton(self): - self.appendGraphBlockButtonProxy = self.scene().addWidget(QtWidgets.QPushButton("+TempoBlock")) - self.appendGraphBlockButtonProxy.widget().clicked.connect(api.appendTempoBlock) - self.appendGraphBlockButtonProxy.setParentItem(self) - self.appendGraphBlockButtonProxy.setPos(0,0) #for now. Afterwards it gets updated by updateGraphBlockTrack . - self.appendGraphBlockButtonProxy.setZValue(10) #this is only the z Value within the Timline ItemGroup - - @property - def items(self): - return self.tempoPoints + self.blockEndMarkers - - def createGraphicItemsFromData(self, staticRepresentationList): - """The X position is, as always, a long tickvalue from the - backend. - The tempo value, has its default 0-line on the middle line, which is the track root. - """ - print ("call") - for item in self.items: - item.setParentItem(None) - self.scene().removeWhenIdle(item) - - self.tempoPoints = [] - self.blockEndMarkers = [] - - xOffset = -6 #adjust items font x offsset - y = -30 #The Y Value adjusts for the offset the text-item creates - - for point in staticRepresentationList: - if not point["type"] == "interpolated": #a real user point or lastInBlock or lastInTrack - p = TempoPoint(self, point) - p.setParentItem(self) - p.setPos(point["position"] / constantsAndConfigs.ticksToPixelRatio + xOffset, y) - self.tempoPoints.append(p) - - def updateGraphBlockTrack(self, staticRepresentationList): - """Handles and visualizes block boundaries. - Also sets the dimensions for the whole timeline-rect""" - #{"type" : "GraphBlock", "id":id(block), "name":block.name, "duration":block.duration, "position":tickCounter, "exportsAllItems":block.exportsAllItems()} - - self.blockListCache = staticRepresentationList #sorted list - for dictExportItem in staticRepresentationList: - blockEndMarker = TempoBlockEndMarker(self, dictExportItem) - self.blockEndMarkers.append(blockEndMarker) - #Position is the block start. We create an end-marker so we need to add the blocks duration. - blockEndMarker.setParentItem(self) - rightBorder = (dictExportItem["duration"] + dictExportItem["position"]) / constantsAndConfigs.ticksToPixelRatio - blockEndMarker.setX(rightBorder) - - #rightBorder is still the last item in the list, so it is the end of the timeline - r = self.rect() - r.setRight(rightBorder) - self.setRect(r) - - if not self.appendGraphBlockButtonProxy: #the append button is a widget, not a QGraphicsItem. So we can only add it after we have a complete scene. - self.createAppendButton() - self.appendGraphBlockButtonProxy.setX((dictExportItem["duration"] + dictExportItem["position"]) / constantsAndConfigs.ticksToPixelRatio + 10) - - def add(self, scenePos): - """Use a scenePos (from a mousePress) to instruct the backend - to create a new tempo point. - - We must first figure out on our own in which block we want - to add the tempo point. - - At the end add() calls the api to generate the new point. - This triggers self.createGraphicItemsFromData which deletes - all points and recreates them. - """ - #y - #y = scenePos.y() - #unitsPerMinute, referenceTicks = self.getYAsBackendValue(y) - unitsPerMinute = 120 - referenceTicks = api.D4 - - print ("timeline") - - #x - sp = scenePos.x() * constantsAndConfigs.ticksToPixelRatio - - if constantsAndConfigs.snapToGrid: - sp = round(sp / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm - - api.insertTempoItemAtAbsolutePosition(sp, unitsPerMinute, referenceTicks) - - -class TempoPoint(QtWidgets.QGraphicsTextItem): - """A point where the values can be edited by the user""" - - def __init__(self, parentTempoTrack, staticExportItem): - super().__init__("") #x,y,w,h - self.staticExportItem = staticExportItem - self.parentTempoTrack = parentTempoTrack - - if not self.staticExportItem["positionInBlock"] == 0: - self.setAcceptHoverEvents(True) - self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable) - self.setCursor(QtCore.Qt.SizeHorCursor) #this sets the cursor while the mouse is over the item. It is independent of AcceptHoverEvents - else: - self.ungrabMouse = api.nothing #to surpress a warning from the context menu - - self.setFont(constantsAndConfigs.musicFont) - self.setHtml("{}".format(constantsAndConfigs.realNoteDisplay[staticExportItem["referenceTicks"]])) - - self.number = QtWidgets.QGraphicsTextItem("") - self.number.setParentItem(self) - self.number.setHtml("{}".format(str(int(staticExportItem["unitsPerMinute"])))) - self.number.setPos(0,0) - - if not self.staticExportItem["graphType"] == "standalone": - self.arrow = QtWidgets.QGraphicsSimpleTextItem("⟶") #unicode long arrow right #http://xahlee.info/comp/unicode_arrows.html - self.arrow.setParentItem(self) - self.arrow.setPos(13,30) - - def getXDifferenceAsBackendValue(self): - """The problem is that we need the position relativ to its - parent block, which we don't use in that way in the GUI. - So we calculate the difference to the old position""" - - oldPositionAbsolute = int(self.staticExportItem["position"]) - newPositionAbsolute = self.pos().x() * constantsAndConfigs.ticksToPixelRatio - if constantsAndConfigs.snapToGrid: - newPositionAbsolute = round(newPositionAbsolute / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm - diff = int(-1 * (oldPositionAbsolute - newPositionAbsolute)) - return diff - - def hoverEnterEvent(self, event): - """To prevent score-item deletion the timeline itself - has a hover event as well that deactivates the score delete - key so we can use it safely for tempoPoints""" - self.grabKeyboard() - #self.setFocus() #do NOT set the focus. GrabKeyboard is enough. The focus will stay even after hoverLeaveEvent so that Delete will delete the last hovered item. not good! - - def hoverLeaveEvent(self, event): - """reverse hoverEnterEvent""" - self.ungrabKeyboard() - - def keyPressEvent(self, event): - """Handle the delete item key. Only works if the item - has the keyboard focus. We grab that on hoverEnter.""" - key = event.key() - - if key == 16777223 and not self.staticExportItem["lastInBlock"] and not self.staticExportItem["positionInBlock"] == 0: - #self.scene().parentView.mainWindow.ui.actionDelete.setEnabled(True) #we never reach hoverLeave since this item gets delete. So re-enable Score-Item delete here. - api.removeTempoItem(self.staticExportItem["id"]) - - def mousePressEvent(self, event): - """Pressing the mouse button is the first action of drag - and drop. We make the mouse cursor invisible so the user - can see where the point is going""" - super().mousePressEvent(event) - self.lastPos = self.pos() - #if event.button() == 1: # QtCore.Qt.MouseButton.LeftButton - # self.setCursor(QtCore.Qt.BlankCursor) - - def wheelEvent(self, event): - if event.delta() > 0: - delta = 1 - else: - delta = -1 - api.changeTempoItem(self.staticExportItem["id"], 0, self.staticExportItem["unitsPerMinute"] + delta, self.staticExportItem["referenceTicks"]) - #super().wheelEvent(event) #if this gets called the graphicView scrolls. - - def mouseReleaseEvent(self, event): - """After moving a point around - send an update to the backend""" - super().mouseReleaseEvent(event) - - if event.button() == 1: # QtCore.Qt.MouseButton.LeftButton - moveInTicks = self.getXDifferenceAsBackendValue() #handles snapToGrid - self.parentTempoTrack.lastTouchedReferenceTicks = self.staticExportItem["referenceTicks"] #used in parentTempoTrack.add() - #event.scenePos().y()) is the mouseCursor, not the item. Since the cursor and the item don't need to be on the same position (the user can't even see the cursor while moving) we need the item.y() - unitsPerMinute = self.staticExportItem["unitsPerMinute"] #stays the same - referenceTicks = self.staticExportItem["referenceTicks"] #stays the same - api.changeTempoItem(self.staticExportItem["id"], moveInTicks, unitsPerMinute, referenceTicks) - - def mouseMoveEvent(self, event): - """Only active when the item is also selected and left - mouse button is down. Not any mouse event, no hover.""" - super().mouseMoveEvent(event) - self.setY(self.lastPos.y()) #only the X axis can be changed. Point can only be moved left and right. - - absoluteXPosition = self.x() * constantsAndConfigs.ticksToPixelRatio - withinBlock = self.staticExportItem["minPossibleAbsolutePosition"] <= absoluteXPosition <= self.staticExportItem["maxPossibleAbsolutePosition"] - if not withinBlock: - if absoluteXPosition < self.staticExportItem["minPossibleAbsolutePosition"]: - self.setX(self.staticExportItem["minPossibleAbsolutePosition"] / constantsAndConfigs.ticksToPixelRatio) - else: - self.setX(self.staticExportItem["maxPossibleAbsolutePosition"] / constantsAndConfigs.ticksToPixelRatio) - - self.globalCursorToItem("x") - - def globalCursorToItem(self, xOrY): - """Make mouse cursor movement not weird when going over the boundaries. - We essentially make self the cursor that cannot leave the rectangle.""" - a = self.scene().parentView.mapFromScene(self.pos()) - b = self.scene().parentView.viewport().mapToGlobal(a) - - if xOrY == "x": - QtGui.QCursor.setPos(b.x(), QtGui.QCursor.pos().y()) - else: - QtGui.QCursor.setPos(QtGui.QCursor.pos().x(), b.y()) - - def contextMenuEvent(self, event): - menu = QtWidgets.QMenu() - - listOfLabelsAndFunctions = [ - ("interpolation type", self.changeInterpolationType), - ("units per minute", self.changeTempoUnits), - ("reference note", self.changeTempoReference), - ] - if not self.staticExportItem["positionInBlock"] == 0: - listOfLabelsAndFunctions.append(("delete", lambda: api.removeTempoItem(self.staticExportItem["id"]))) - - for text, function in listOfLabelsAndFunctions: - if text == "separator": - self.addSeparator() - else: - a = QtWidgets.QAction(text, menu) - menu.addAction(a) - a.triggered.connect(function) - - pos = QtGui.QCursor.pos() - pos.setY(pos.y() + 5) - self.ungrabMouse() #if we don't ungrab and the user clicks the context menu "away" by clicking in an empty area this will still get registered as mouseclick belonging to the current item - menu.exec_(pos) - - def mouseDoubleClickEvent(self, event): - self.changeTempoUnits() - - def changeInterpolationType(self): - li = api.getListOfGraphInterpolationTypesAsStrings() - graphTypeString = QtWidgets.QInputDialog.getItem(self.scene().parentView, "Interpolation Type", "choose Interpolation Type", li, li.index(self.staticExportItem["graphType"]), False) #list and default value from that list - if graphTypeString[1]: #[1] bool if canceled - api.changeTempoItemInterpolation(self.staticExportItem["id"], graphTypeString[0]) - - def changeTempoUnits(self): - #TODO: unify changeTempoUnits and changeTempoReference - self.parentTempoTrack.lastTouchedReferenceTicks = self.staticExportItem["referenceTicks"] - unitsPerMinute = self.staticExportItem["unitsPerMinute"] - referenceTicks = self.staticExportItem["referenceTicks"] - ccValue = QtWidgets.QInputDialog.getInt(self.scene().parentView, "Units per Minute", "how many reference notes per minute?", value=unitsPerMinute, min=20, max=999) - if ccValue[1]: #[1] bool if canceled - moveInTicks = self.getXDifferenceAsBackendValue() - unitsPerMinute = ccValue[0] - api.changeTempoItem(self.staticExportItem["id"], moveInTicks, unitsPerMinute, referenceTicks) - - def changeTempoReference(self): - #TODO: unify changeTempoUnits and changeTempoReference - self.parentTempoTrack.lastTouchedReferenceTicks = self.staticExportItem["referenceTicks"] - unitsPerMinute = self.staticExportItem["unitsPerMinute"] - referenceTicks = self.staticExportItem["referenceTicks"] - rhythmString = QtWidgets.QInputDialog.getItem(self.scene().parentView, "Reference Note", "What is the reference note for the tempo?", constantsAndConfigs.prettyExtendedRhythmsStrings, constantsAndConfigs.prettyExtendedRhythmsValues.index(referenceTicks), False) - if rhythmString[1]: #bool. Canceled? - for k, v in constantsAndConfigs.prettyExtendedRhythms: - if v == rhythmString[0]: - moveInTicks = self.getXDifferenceAsBackendValue() - referenceTicks = k - self.parentTempoTrack.lastTouchedReferenceTicks = k - api.changeTempoItem(self.staticExportItem["id"], moveInTicks, unitsPerMinute, referenceTicks) - - -class TempoBlockEndMarker(QtWidgets.QGraphicsLineItem): - def __init__(self, parentCCPath, staticExportItem): - #super().__init__(0,-1*parentCCPath.rect().height(),0, parentCCPath.rect().height()) #x1, y1, x2, y2 - super().__init__(0,-1, 0, parentCCPath.rect().height() + 4) #x1, y1, x2, y2 - assert not self.line().isNull() #needed to do self.line().setLength(x) - self.parentCCPath = parentCCPath - self.staticExportItem = staticExportItem - self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable|QtWidgets.QGraphicsItem.ItemClipsToShape) - self.setAcceptHoverEvents(True) - self.setCursor(QtCore.Qt.SizeHorCursor) - #self.setBrush(QtGui.QColor("red")) - pen = QtGui.QPen() # creates a default pen - if not self.staticExportItem["exportsAllItems"]: - pen.setStyle(QtCore.Qt.DotLine) - pen.setWidth(2) - self.setPen(pen) - - self.inactivePen = pen - self.inactivePen.setColor(QtGui.QColor("black")) - - self.activePen = QtGui.QPen(pen) - self.activePen.setColor(QtGui.QColor("cyan")) - - def shape(self): - """Return a more accurate shape for this item so that - mouse hovering is more accurate""" - path = QtGui.QPainterPath() - path.addRect(QtCore.QRectF(-2, -2, 4, self.parentCCPath.rect().height() + 4 )) #this is directly related to inits parameter x, y, w, h - return path - - def hoverEnterEvent(self, event): - self.setPen(self.activePen) - - def hoverLeaveEvent(self, event): - self.setPen(self.inactivePen) - - def allItemsRightOfMe(self): - for item in self.parentCCPath.items: - if item.x()+7 >= self.x(): - yield item - - def itemsLeftAndRightOfMe(self): - left = [] - right = [] - for item in self.parentCCPath.items: - if item.x()+7 >= self.x(): - right.append(item) - else: - left.append(item) - return left, right - - def mousePressEvent(self, event): - super().mousePressEvent(event) - self.lastPos = self.pos() - self.itemsLeft, self.itemsRight = self.itemsLeftAndRightOfMe() - - #if event.button() == 1: # QtCore.Qt.MouseButton.LeftButton - # for i in self.allItemsRightOfMe(): - # i.hide() - # self.parentCCPath.appendGraphBlockButtonProxy.hide() - - def mouseMoveEvent(self, event): - """Only active when the item is also selected and left - mouse button is down. Not any mouse event, no hover. - - This does not actually change the backend items. The change is done on mouseRelease""" - super().mouseMoveEvent(event) - self.setY(self.lastPos.y()) - - delta = event.scenePos() - event.lastScenePos() - deltaX = delta.x() - - endingRelativeToBlockStart = self.x() * constantsAndConfigs.ticksToPixelRatio - self.staticExportItem["position"] - - if deltaX < 0 and endingRelativeToBlockStart < api.D1: #some musical sanity. Also prevents that the block gets a negative duration and the backends throws an exception - event.accept() - return None - - for item in self.itemsRight: - item.moveBy(deltaX, 0) - for item in self.itemsLeft: - if item.x() + 15 >= self.x(): - item.hide() - - #self.parentCCPath.appendGraphBlockButtonProxy.moveBy(deltaX, 0) - self.parentCCPath.appendGraphBlockButtonProxy.hide() - - def mouseReleaseEvent(self, event): - """After moving a point around - send an update to the backend""" - super().mouseReleaseEvent(event) - if event.button() == 1: # QtCore.Qt.MouseButton.LeftButton - self.parentCCPath.appendGraphBlockButtonProxy.show() - endingRelativeToBlockStart = self.x() * constantsAndConfigs.ticksToPixelRatio - self.staticExportItem["position"] - if constantsAndConfigs.snapToGrid: - endingRelativeToBlockStart = round(endingRelativeToBlockStart / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm - - assert endingRelativeToBlockStart > 0 - api.changeTempoBlockDuration(self.staticExportItem["id"], endingRelativeToBlockStart) - - def deactivate_contextMenuEvent(self, event): - menu = QtWidgets.QMenu() - - listOfLabelsAndFunctions = [ - ("restore to full duration ", self.contextRestore), - ] - - for text, function in listOfLabelsAndFunctions: - if text == "separator": - self.addSeparator() - else: - a = QtWidgets.QAction(text, menu) - menu.addAction(a) - a.triggered.connect(function) - - pos = QtGui.QCursor.pos() - pos.setY(pos.y() + 5) - self.ungrabMouse() #if we don't ungrab and the user clicks the context menu "away" by clicking in an empty area this will still get registered as mouseclick belonging to the current item - menu.exec_(pos) - - def contextRestore(self): - print (self.staticExportItem) - #api.changeTempoBlockDuration(self.staticExportItem["id"], endingRelativeToBlockStart) diff --git a/template b/template index ae1cf29..c977571 160000 --- a/template +++ b/template @@ -1 +1 @@ -Subproject commit ae1cf29e120cb0e3df53d152b12a67d21483d986 +Subproject commit c9775716ea50d062415edc81cfee71911f67b3ef