#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of Laborejo ( https://www.laborejo.org ) Laborejo 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; logger = logging.getLogger(__name__); logger.info("import") from weakref import WeakSet from weakref import ref as weakref_ref from .items import * #loading from file needs all items. class Block(object): #allBlocks = WeakValueDictionary() #key is the blockId, value is the weak reference to the Block 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. #NEVER!! iteratre over allBlocks if you don't know what you are doing. This contains deleted blocks as well. firstBlockWithNewContentDuringDeserializeToObject = dict() #this is not resetted anywhere since each load is a program start. def __init__(self, track): self.data = list() self._name = [ str(id(self)) ] #list of len==1 so it is mutable for linked blocks. See @property below self._minimumInTicks = [0] # if this is bigger than the actual duration-sum of the content this will be used instead. Advice: best used in the form of x*210 (multiple of real base-durations) #is content linked, thats why it is a mutable list of len==1. See @property below self.linkedContentBlocks = WeakSet() #only new standalone blocks use this empty WeakSet. once you contentLink a block it will be overwritten. self._secondInit(parentTrack = track) def _secondInit(self, parentTrack): """see Score._secondInit""" self._parentTrack = weakref_ref(parentTrack) #a block can only be in one track. self.localCursorIndex = 0 self.linkedContentBlocks.add(self) self.rememberBlock() @property def name(self): """name must be mutable so it is shared between content link blocks""" return self._name[0] @name.setter def name(self, name:str): listId = id(self._name) #just a precaution self._name.pop() self._name.append(name) assert len(self._name) == 1 assert listId == id(self._name) @property def minimumInTicks(self): """minimumInTicks must be mutable so it is shared between content link blocks""" return self._minimumInTicks[0] @minimumInTicks.setter def minimumInTicks(self, newValue:int): """Keep the mutable list at all cost""" listId = id(self._minimumInTicks) #just a precaution self._minimumInTicks.pop() self._minimumInTicks.append(newValue) assert len(self._minimumInTicks) == 1 assert listId == id(self._minimumInTicks) @classmethod def instanceFromSerializedData(cls, serializedObject, parentObject): """see Score.instanceFromSerializedData""" assert cls.__name__ == serializedObject["class"] self = cls.__new__(cls) if serializedObject["data"] is None: #Found a content linked block which already has one member of its group in the score firstBlock = Block.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] #block with the same contentGroup. This is the one with the real data. self.data = firstBlock.data self._minimumInTicks = firstBlock._minimumInTicks #mutable list self._name = firstBlock._name #mutable list self.linkedContentBlocks = firstBlock.linkedContentBlocks #add self to this is in _secondInit else: #found a stand-alone block or the first one of a content link group self.linkedContentBlocks = WeakSet() self.data = [eval(item["class"]).instanceFromSerializedData(item, parentObject = self) for item in serializedObject["data"]] self._name = [ str(serializedObject["name"]) ] #saved as string, used as list self._minimumInTicks = [ int(serializedObject["minimumInTicks"]) ] #saved as int, used as list Block.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] = self for item in self.data: item.parentBlocks.add(self) self._secondInit(parentTrack = parentObject) return self def serialize(self): """Return a serialized data from this instance. Used for save and load. Can be called in a chain by subclasses with super().serialize The main difference between serialize and exportObject is that serialize does not compute anything. I just saves the state without calulating note on and off or stem directions, for example. """ #result = super()._serialize() #call this in child classes result = {} result["class"] = self.__class__.__name__ result["name"] = self.name result["minimumInTicks"] = self.minimumInTicks #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.parentTrack.parentData.allBlocks(): blockId = id(block) dataId = id(block.data) if dataId == contentLinkGroupId: if blockId == id(self): #first block with this dataId found. result["data"] = [item.serialize() for item in self.data] 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: #loop ran through. This never happens. return result def rememberBlock(self): oid = id(self) Block.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 @property def parentTrack(self): if self._parentTrack: return self._parentTrack() #return the real parentTrack else: assert self._parentTrack is None return self._parentTrack @parentTrack.setter def parentTrack(self, newTrack): if not self._parentTrack: #because it was None/not in a track self._parentTrack = WeakSet() if newTrack: self._parentTrack = weakref_ref(newTrack) else: self._parentTrack = None def copy(self, newParentTrack, nameSuffix="-copy"): """Return an independet copy of this block. It will not be inserted into a track here but in the parentTrack It is by design only possible that a block will be inserted in the same track, next to the original block. It can be moved later by the api module which will reset the parentTrack. But at first the parentTrack stays the same.""" new = Block(newParentTrack) assert newParentTrack #Do not change new.linkedContentBlocks! Copy creates a stand alone copy. assert len(new.linkedContentBlocks) == 1 for item in self.data: copyItem = item.copy() new.data.append(copyItem) assert not copyItem.parentBlocks #parentBlock was empty until now copyItem.parentBlocks.add(new) if self in copyItem.parentBlocks: #TODO: investigate copyItem.parentBlocks.remove(self) if self.name.endswith(nameSuffix): #name is mutable, but getter and setter deal with that. new.name = self.name else: new.name = self.name + nameSuffix new._minimumInTicks = self._minimumInTicks[:] #it is a mutable value, we make a copy of the list (which has an int inside, which is immutable and copied automatically) return new def getUnlinkedData(self): """ Returns a new list with copies of items. Set and handled for undo/redo by the api. """ newData = [] newParentBlocks = WeakSet() newParentBlocks.add(self) for item in self.data: copyItem = item.copy() newData.append(copyItem) copyItem.parentBlocks = newParentBlocks assert len(newData) == len(self.data) return newData def getDataAsDict(self): return { "name" : self.name, "minimumInTicks" : self.minimumInTicks, "id" : id(self), #read only. Not applied in putDataFromDict } def putDataFromDict(self, dataDict): """modify inplace. Useful for a gui function. Compatible with the data from getDataAsDict""" self.name = dataDict["name"] self.minimumInTicks = dataDict["minimumInTicks"] def contentLink(self): """Return a copy where only certain parameters like Content are linked. Others can be changed. It will not be inserted into a track here but in the parentTrack It is by design only possible that a block will be inserted in the same track, next to the original block. It can be moved later by the api module which will reset the parentTrack. But at first the parentTrack stays the same. """ assert self.parentTrack new = Block(self.parentTrack) new.linkedContentBlocks = self.linkedContentBlocks new.linkedContentBlocks.add(new) new.data = self.data #mutable. Will change in all blocks together. new._name = self._name #mutable as well. new._minimumInTicks = self._minimumInTicks #mutable #Add the new block to the parentBlocks set but don't delete self from it, as in copy(). The items are now in more than one block simultaniously for item in new.data: item.parentBlocks.add(new) return new def linkedContentBlocksInScore(self): """filters linkedContentBlocks to only include those currently in the score. Not those in the undo repository """ return (block for block in self.linkedContentBlocks if block.parentTrack) def duration(self): """The first block might have an upbeat. We check that here and not in the track.""" actualBlockDuration = 0 for item in self.data: actualBlockDuration += item.logicalDuration() if actualBlockDuration >= self.minimumInTicks: return actualBlockDuration else: return self.minimumInTicks def staticExportEndMarkerDuration(self): actualBlockDuration = 0 for item in self.data: actualBlockDuration += item.logicalDuration() if actualBlockDuration >= self.minimumInTicks: #this also guarantees that the substraction below is > 0. return 0 else: return self.minimumInTicks - actualBlockDuration #this is the difference to self.duration() def position(self): """the position of the subcursor in this block""" return self.localCursorIndex def left(self): if self.localCursorIndex > 0: self.localCursorIndex -= 1 return True else: return False #Already at the start. def right(self): if not self.isAppending(): self.localCursorIndex += 1 return True else: return False #Already at the end. def head(self): self.localCursorIndex = 0 def tail(self): self.localCursorIndex = len(self.data) #eventhough len counts from 1 and the cursorIndex from 0 we want exactly to be after the last item in data. def goToItem(self, itemInstance): if itemInstance: self.head() while self.right(): item = self.currentItem() if item is itemInstance: return True else: raise ValueError("Item not in this block. ", self, itemInstance) elif itemInstance is None: self.tail() else: raise ValueError("You must go to an item or None, not to: ", itemInstance) def currentItem(self): """Can be used with goToItem""" if not self.isAppending(): return self.data[self.localCursorIndex] else: return None def previousItem(self): """Can be used with goToItem""" if self.localCursorIndex > 0: return self.data[self.localCursorIndex-1] else: return None def nextItem(self): """Can be used with goToItem""" if self.localCursorIndex+1 >= len(self.data): #one before appending or appending itself return None else: return self.data[self.localCursorIndex+1] def insert(self, item): self.data.insert(self.localCursorIndex, item) #we do not need to check if appending or not. list.insert appends if the index is higher then len() #self.localCursorIndex += 1 #we don't need to go right here because track.insert() is calling its own right() directly after insert, which triggers block.right() item.parentBlocks.add(self) def delete(self): """The commented out is the immediate garbage collector which made sure that deleted item are not in the allNotes weakref dict any more. However, this does not happen any more since we introduced undo which keeps a saved version. For consitency reasons we chose not to save a copy in the undo register (return ...copy()) but the real item. If this ever leads to problems with the weakref dict we must reintroduce the copy""" if not self.isAppending(): result = self.data[self.localCursorIndex] del self.data[self.localCursorIndex] #we don't need to delete self from item.parentBlocks. It is automatically deleted in all contentLinked blocks, of course including its parentBlocks WeakSet() return result def isAppending(self): return self.localCursorIndex == len(self.data) #len counts from 1 and the cursorIndex from 0. So if they are the same we are one cursor position right of last item def lilypond(self, carryLilypondRanges): """Called by track.lilypond(), returns a string. carryLilypondRanges is handed from item to item for ranges such as tuplets. Can act like a stack or simply remember stuff. """ #Calculate padding from block minimum tick duration in D1024 notes. Lilypond doesn't care. difference = self.staticExportEndMarkerDuration() if difference: skipstring = f" s1024*{int(difference / D1024)}" else: skipstring = "" #Format: Block name as comment, followed by a music expression { block data } #The music expression doesn't hurt, but is useful for repeats return "\n % Block: " + self.name + "\n { " + " ".join(item.lilypond(carryLilypondRanges) for item in self.data) + skipstring + " } \n"