#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), 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; logger = logging.getLogger(__name__); logger.info("import") #Standard Library Modules from weakref import WeakKeyDictionary, WeakValueDictionary from collections import OrderedDict #Third Party Modules #Template Modules import template.engine.sequencer #Our modules from .block import Block from .ccsubtrack import GraphTrackCC from .items import * #parseRight and trackState need all items. class DynamicSettingsSignature(object): """Holds the meaning of dynamic keywords for one track. Only used once per Track. Cannot be inserted. Each track needs a different one because "Forte" for a brass instrument has a different meaning than for a string instruments. Tracks are instruments. Do not confuse with trackState.dynamicSignatures, which is a stack of actual dyn-signatures like "forte". """ def __init__(self): #self.dynamics = filled in by self.reset self.reset() self._secondInit(parentTrack = None) def reset(self): """Reset to the programs default values. This is a function and not in init because the api wants to reset as well.""" self.dynamics = { #This is a dict instead of direct values so that dynamicSignature can use a string keyword to get their value and don't have to use getattr(). "ppppp":15, "pppp":31, "ppp":43, "pp":53, "p":69, "mp":77, "mf":90, "f":98, "ff":108, "fff":116, "ffff":127, "custom":64, "tacet":0, "fp":98, "sf":98, "sff":108, "sp":69, "spp":53, "sfz":116, #TODO: accent > ? } def __setattr__(self, name, value): super().__setattr__(name, value) self.sanityCheck() def _secondInit(self, parentTrack): """see Item._secondInit""" #ignore parentTrack pass @classmethod def instanceFromSerializedData(cls, serializedData, parentObject): """see Score.instanceFromSerializedData""" assert cls.__name__ == serializedData["class"] self = cls.__new__(cls) self.dynamics = {key:int(value) for key, value in serializedData["dynamics"].items()} self._secondInit(parentTrack = parentObject) return self def serialize(self): result = {} result["class"] = self.__class__.__name__ result["dynamics"] = self.dynamics return result def copy(self): """ Since there is only one DynSig, directly in the track, this copy is only called when doing a track-copy which copies the track itself. """ new = DynamicSettingsSignature() new.dynamics = self.dynamics.copy() return new def sanityCheck(self): for key, value in self.dynamics.items(): if not 0 <= value <= 127: warn("Dynamic {} of value {} not allowed. Choose a dynamic between 0 and 127. Now set to 0, please edit manually.".format(key, value)) self.dynamics[key] = 0 class DurationSettingsSignature(object): """Only used once per Track. Cannot be inserted, is already in the track. Can and should be modified though.""" class DurationMod(object): """A descriptor that converts a string to a function and checks if a duration mod string is valid. We need this as descriptor because we want the user to change a dur-sig by writing a new calculation in string form as e.g. defaultOff. Each time a new string is set we create a new DurationMod which holds the evaluated calculation as a function, which was tested if valid.""" class EvaluatedFunction(object): """This is a callable class as substitute for a function. We need this as a class to get the __str__ parameter since we use strings as a method of user input. These strings get saved and loaded. """ def __init__(self, string): try: testFunction = lambda x: eval(string) testFunction(D4) self.evaluated = lambda x: eval(string) self.string = string self.evaluated.__str__ = self.__str__ except Exception as err: warn("Syntax Warning: duration processing not possible. Only 'x' and numbers are allowed for calculations. Please check your input: {} ".format(string)) self.string = string self.evaluated = lambda x: 0 def __call__(self, parameter): return self.evaluated(parameter) def __str__(self): return self.string def __init__(self): self.data = WeakKeyDictionary() def __get__(self, instance, owner): # we get here when someone calls instance.attribute, and attribute is a DurationMod instance #return self.data.get(instance) return self.data[instance] def __set__(self, instance, string): # we get here when someone calls instance.attribute = val, and attribute is a DurationMod instance self.data[instance] = self.EvaluatedFunction(string) #Descriptors belong in the class scope defaultOn = DurationMod() defaultOff = DurationMod() staccatoOn = DurationMod() staccatoOff = DurationMod() tenutoOn = DurationMod() tenutoOff = DurationMod() legatoOn = DurationMod() legatoOff = DurationMod() def __init__(self): """-On- means the modification on the left side of a note duration, -off- means the right side. Note on and off. All values will be added to the start/end of the notes duration. Since the start as always "0" in this reference frame you essentially set the note on here. Duration keywords like D4 are supported. The special variable "x" will be provided in the scope to be used in the string. Examples: "0" means no change. defaultOn= "D8" will result in a duration starting an eighth later than expected defaultOff = "-0.10 * x" will evaluate to negative 10% of the original duration (x) which will then be added to the original duration, resulting in the note off to be 10% less than normally. However, that scales quite unnaturally to large duration and we better use a fixed offset. If you want to set your duration, e.g. note off, to an absolute value start by adding the negative original duration and build up from there. "-1*x + D8" always results in an eigth note duration. """ self.reset() #fills in all our values self._secondInit(parentTrack = None) def reset(self): """This is not in init because the api wants to reset as well""" self.defaultOn = "0" self.defaultOff = "-1 * D64 if x <= D16 else -1 * D32" self.staccatoOn = "0" self.staccatoOff = "-1 * x + D64" self.tenutoOn = "0" self.tenutoOff = "0" self.legatoOn = "0" #for slurs self.legatoOff = "D128" #overlap def _secondInit(self, parentTrack): """see Item._secondInit""" #ignore parentTrack pass @classmethod def instanceFromSerializedData(cls, serializedData, parentObject): """see Score.instanceFromSerializedData""" assert cls.__name__ == serializedData["class"] self = cls.__new__(cls) self.defaultOn = str(serializedData["defaultOn"]) self.defaultOff = str(serializedData["defaultOff"]) self.staccatoOn = str(serializedData["staccatoOn"]) self.staccatoOff = str(serializedData["staccatoOff"]) self.tenutoOn = str(serializedData["tenutoOn"]) self.tenutoOff = str(serializedData["tenutoOff"]) self.legatoOn = str(serializedData["legatoOn"]) self.legatoOff = str(serializedData["legatoOff"]) self._secondInit(parentTrack = parentObject) return self def serialize(self): result = {} result["class"] = self.__class__.__name__ result["defaultOn"] = str(self.defaultOn) result["defaultOff"] = str(self.defaultOff) result["staccatoOn"] = str(self.staccatoOn) result["staccatoOff"] = str(self.staccatoOff) result["tenutoOn"] = str(self.tenutoOn) result["tenutoOff"] = str(self.tenutoOff) result["legatoOn"] = str(self.legatoOn) result["legatoOff"] = str(self.legatoOff) return result def copy(self): #Strings are not mutable so they are just copied by new assignment #Also the strings will trigger the creation of new functions immediately new = DurationSettingsSignature() new.defaultOn = str(self.defaultOn) new.defaultOff = str(self.defaultOff) new.staccatoOn = str(self.staccatoOn) new.staccatoOff = str(self.staccatoOff) new.tenutoOn = str(self.tenutoOn) new.tenutoOff = str(self.tenutoOff) new.legatoOn = str(self.legatoOn) new.legatoOff = str(self.legatoOff) return new class TrackState(object): """TrackState stays not the same for one track but gets recreated, at least in track.head(). This means init is always position 0.""" #in init these will get recreated all the time. This confuses cashing and hashing defaultClef = Clef("treble") defaultDynamicSignature = DynamicSignature("custom") defaultMetricalInstruction = MetricalInstruction(tuple(), isMetrical = False) defaultKeySignature = KeySignature(20, [0,0,0,0,0,0,0]) def __init__(self, track): self.track = track self.tickindex = 0 #The tickindex has no metrical connection. It doesn't care about upbeats, barlines or timesigs. self.ticksSinceLastMeasureStartLive = -1*track.upbeatInTicks self.blockindex = 0 self.ticksSinceLastMeasureStart = 0 #Only for export. self.barlines = [] #a list of ints/tickpositions. These are the non-editable, non-movable single barlines. #stacks. There is always one item left in the stack, the default: self.keySignatures = [self.defaultKeySignature] #C Major self.clefs = [self.defaultClef] self.metricalInstructions = [self.defaultMetricalInstruction] #no barlines, no metrical information. self.dynamicSignatures = [self.defaultDynamicSignature] self.dynamicRamp = None #Not for cursor movement so it is not a stack. self.EXPORTtiedNoteExportObjectsWaitingForClosing = {} #pitch:noteExportObject . Empty during cursor movement, filled during export. self.duringLegatoSlur = False #there are no nested legato slurs self.duringBeamGroup = False #no nested beam groups. self.midiChannels = [track.initialMidiChannel] #these are just integers, not items. items.ChannelChange parsing changes this and items automatically get the new value. A stack of midi channels allows the cursor to know the current channel for immediate playback/feedback. self.instrumentChanges = [InstrumentChange(track.initialMidiProgram, track.initialMidiBankMsb, track.initialMidiBankLsb, track.initialShortInstrumentName, )] self.midiTranspose = track.midiTranspose # TODO: When a change item for the following functions is introduced they need to get parsed in track.parseLeft and track.parseRight def position(self): """The position in the track""" #[0, 1, 2, 3][:3] means slice item 0, 1, 2 but not include 3. result = 0 for block in self.track.blocks[:self.blockindex]: #all blocks without the current one. #TODO: the following assert fails. somewhere in the code the local cursor index gets not updated properly. most likely in insert, delete or track.tail, track.head #This is the reason why inserting in contend linket blocks results in the wrong cursor position if we rely on keeping track of the track position manually or using the local block positions. We can only rely on the current one. #assert len(block.data) == block.localCursorIndex #does not hold for the current block because it was not completely traversed yet. result += len(block.data) + 1 #+1 for the appending position result += self.track.currentBlock().localCursorIndex #finally add the position in the current block which is somewhere in the middle or so. at least not the same as len(currentBlock) return result def index(self): if self.track in self.track.parentData.tracks: return self.track.parentData.tracks.index(self.track) else: return None #hidden def clef(self): return self.clefs[-1] def keySignature(self): return self.keySignatures[-1] def metricalInstruction(self): return self.metricalInstructions[-1] def dynamicSignature(self): return self.dynamicSignatures[-1] def midiChannel(self): return self.midiChannels[-1] def instrumentChange(self): return self.instrumentChanges[-1] def isAppending(self): """Return if the cursor is on the end of a block (which is also the end of the track)""" return self.track.currentBlock().isAppending() #You cannot cache the trackState, especially not keysignatures. Leave this in as a reminder. #An item can be in two blocks which places it in two different contexts/states. If you want to know the context of an item you really have to put the cursor on it. def createCached(self): """Create a static version of the current state which can be put into items. Apply to selection does not have to do a local cursor walk then to get keysigs. This does not change anything for the items. They still get a keysig as parameter, if they need it. The alternative would be to let the item get the cached version themselves. That is not versatile enough for complex musical operations. The cache is created during track.staticRepresentation so we can be sure that it is always up-to-date. The format and the names are different as well. You cannot use a cached trackState as a drop-in replacement for the real state. """ #TODO: for now we only cache values that we know we need. This should keep this function under control and prevent misuse from lazyness, like explained in the docstring. #return { # "keySignature" : self.keySignatures[-1], #} pass class Track(object): allTracks = WeakValueDictionary() #key is the trackId, value is the weak reference to the Track. Deleted tracks (from the current session) and hidden tracks are in here as well. #def __repr__(self) -> str: # return f"Laborejo Track: {self.sequencerInterface.name}" def __init__(self, parentData, name=None): self.parentData = parentData self.sequencerInterface = template.engine.sequencer.SequencerInterface(parentTrack=self, name=name) self.ccGraphTracks = {} #cc number, graphTrackCC. Every CC is its own SequencerTrack that routes to this tracks jack midi out self.ccChannels = tuple() #unsorted 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)] self.durationSettingsSignature = DurationSettingsSignature() #only one per track self.dynamicSettingsSignature = DynamicSettingsSignature() #only one per track self.upbeatInTicks = 0 #playback does not care about upbeats. But exporting does. for example barlines. self.double = False #if true add 5 stafflines extra below. Clef will always stay in the upper staff. This is a representation-hint. E.g. a gui can offer a bigger staff system for the user. #A set of starting values. They are user editable and represent the tracks playback state on head(). Therefore they get saved. self.initialMidiChannel = 0 # 0-15 self.initialMidiProgram = -1 # 0-127. -1 is "don't send" self.initialMidiBankMsb = 0 # 0-127. Depends on program >= 0 self.initialMidiBankLsb = 0 # 0-127. Depends on program >= 0 self.midiTranspose = 0 # -127 to +127 but the result is never outside of 0-127.1 Cannot change during the track. #The instrument names are also handled by the trackState so that the cursor knows about instrument changes self.initialInstrumentName = "" #different than the track name. e.g. "Violin" self.initialShortInstrumentName = "" # e.g. "vl" self.asMetronomeData = None #This track as metronome version. Is always up to date through export. self._processAfterInit() def _processAfterInit(self): """Call this after either init or instanceFromSerializedData""" self.state = TrackState(self) Track.allTracks[id(self)] = self #remember track as weakref #weakref_finalize(self, print, "deleted track "+str(id(self))) @property def hidden(self): """Is this editable or read-only (and not shown in a possible GUI). hidden still emits playback and exports to other formats""" return not self in self.parentData.tracks @property def name(self): return self.sequencerInterface.name @name.setter def name(self, newValue): self.sequencerInterface.name = newValue def duration(self): """Return the duration of the whole track, in ticks""" result = 0 for block in self.blocks: result += block.duration() return result def listOfCCsInThisTrack(self): return list(self.ccGraphTracks.keys()) def appendBlock(self): new = Block(self) self.blocks.append(new) return new def appendExistingBlock(self, block): assert type(block) is Block, block block.parentTrack = self self.blocks.append(block) def deleteBlock(self, block): """Undo of deleteBlock is rearrange blocks.""" if len(self.blocks) > 1: originalPosition = self.state.position() #block.parentTrack = None We keep the parent track for undo. Through the linear nature of undo it is guaranteed that the track instance will still exists when this delete attempts undo. self.blocks.remove(block) #eventhough this must be undone we cannot keep the deleted block in self.blocks. Self.blocks is designed for only active blocks. block.parentTrack = None self.toPosition(originalPosition, strict = False) #has head() in it. strict=False just leaves the cursor at head if we can't return to the position because it got deleted. return self, block #self is parent Track else: return False def currentBlock(self): block = self.blocks[self.state.blockindex] return block def duplicateBlock(self, block): originalPosition = self.state.position() index = self.blocks.index(block) copy = block.copy(newParentTrack = self) self.blocks.insert(index +1, copy) self.toPosition(originalPosition) #has head() in it def duplicateContentLinkBlock(self, block): originalPosition = self.state.position() index = self.blocks.index(block) linked = block.contentLink() self.blocks.insert(index +1, linked) self.toPosition(originalPosition) #has head() in it def hasContentLinks(self): return any(len(block.linkedContentBlocks) > 1 for block in self.blocks) def asListOfBlockIds(self): """Return an ordered list of block ids""" result = [] for block in self.blocks: result.append(id(block)) return result def rearrangeBlocks(self, listOfBlockIds): """Reorder the blocks in this track. Achtung! Not including a block will delete this block. Undo is done by the api.""" originalPosition = self.state.position() #blocksDict = self.blocksAsDict() newBlockArrangement = [] #first remove all parent tracks from the current blocks. They will be added later again for the tracks that really stay in the track. for block in self.blocks: block.parentTrack = None for idLong in listOfBlockIds: actualBlock = Block.allBlocks[idLong] actualBlock.parentTrack = self newBlockArrangement.append(actualBlock) #This also checks if there are IDs which match no blocks. allBlocks includes deleted ones for undo as well. #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 #The old position may not exist anymore (block moved to other track) but we can at least try (strict = False) to return to it. self.toPosition(originalPosition, strict = False) #has head() in it. 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 head(self): """no left-parsing needed. The beginning of a track has always the same parameters, an empty TrackState.""" for block in self.blocks: block.head() self.state = TrackState(self) def tail(self): """no shortcuts. Parse right till the end, parsing every item on its way.""" while self.right(): True assert self.currentItem() is None #appending position def left(self): if self.currentBlock().left(): #side effect: actual moving self.parseLeft() assert self.state.position() >= 0 return 1 elif self.state.blockindex > 0: blockBeforeCrossing = self.currentBlock() self.state.blockindex -= 1 assert not blockBeforeCrossing is self.currentBlock() assert self.currentBlock() is self.blocks[self.state.blockindex] self.currentBlock().tail() #in case we have a block multiple times in the track this resets it from the last time we traversed it #block end. This is already the next state. right now we are in the next block left already. #instead of parsing left: lastBlock = self.blocks[self.state.blockindex] self.state.tickindex -= lastBlock.staticExportEndMarkerDuration() return 2 #moving succeeded, but no Item was returned. This is a gap between two blocks. else: return 0 #track beginning def right(self): if self.currentBlock().right(): #side effect: actual moving self.parseRight() return 1 elif self.state.blockindex+1 < len(self.blocks): #block counts from 0, len from 1. If block+1 is smaller than the amount of blocks this means this is not the final block. #block end. This is already the previous state. right now we are in the next block already. self.state.blockindex += 1 self.currentBlock().head() #in case we have a block multiple times in the track this resets it from the last time we traversed it #instead of parsing right: lastBlock = self.blocks[self.state.blockindex-1] self.state.tickindex += lastBlock.staticExportEndMarkerDuration() #why -1? see comment above return 2 #moving succeeded, but no Item was returned. This is a gap between two blocks. else: return 0 #track end def measureLeft(self): """We don't use goToTickIndex for this because that is a slow function that starts from head()""" goalInTicks = self.state.tickindex - self.state.metricalInstruction().oneMeasureInTicks if self.state.metricalInstruction().oneMeasureInTicks == 0: goalInTicks -= D1 while self.left(): if self.state.tickindex <= goalInTicks: break def measureRight(self): """We don't use goToTickIndex for this because that is a slow function that starts from head()""" if type(self.currentItem()) is MetricalInstruction: #this mostly happens in the beginning of a track when the metrical instruction is the first item goalInTicks = self.state.tickindex + self.currentItem().oneMeasureInTicks else: goalInTicks = self.state.tickindex + self.state.metricalInstruction().oneMeasureInTicks if self.state.metricalInstruction().oneMeasureInTicks == 0: goalInTicks += D1 while self.right(): if self.state.tickindex >= goalInTicks: break def measureStart(self): if self.state.ticksSinceLastMeasureStartLive < 0: #upbeats are lower than 0 while not self.state.ticksSinceLastMeasureStartLive == 0: self.right() else: while not self.state.ticksSinceLastMeasureStartLive <= 0: self.left() while self.left(): #stops automatically at the beginning of the track curItem = self.currentItem() if curItem and curItem.logicalDuration() > 0 or not self.state.ticksSinceLastMeasureStartLive == 0: #we found the boundary between this measure and the one before it self.right() break else: self.head() def startOfBlock(self): currentBlock = self.currentBlock() #we stay in this block so this doesn't need to change. while not currentBlock.localCursorIndex == 0: self.left() def endOfBlock(self): """Go to the end of this block""" currentBlock = self.currentBlock() #we stay in this block so this doesn't need to change. while not currentBlock.isAppending(): self.right() def goToTickindex(self, targetTickIndex, skipOverZeroDurations = True): """Used for moving the active Track up/down. If you want to go to a specific position, use the normal item index.""" #if not targetTickIndex == self.state.tickindex: #we are already there? #TODO: this is purely for performance. Leave it out until profiling. self.head() #resets everything, including the state. while self.state.tickindex < targetTickIndex: if not self.right(): #track ends? very well. break if skipOverZeroDurations: while self.currentItem() and self.currentItem().logicalDuration() == 0: #skip over zero-duration items to find the next likely edit point self.right() return True def toPosition(self, position, strict = True): """wants track.state.position() as parameter""" self.head() while not self.state.position() == position: if not self.right(): if strict: raise ValueError("Position does not exist in track", position, self) else: self.head() break def toBlockAndLocalCursorIndex(self, blockindex, localCursorIndex): """A very good way to find a position, while looking the other way if the item at this position is indeed the item we are looking for or maybe just a copy. Remember: Blocks are unique! Items are are only unique within a block""" self.head() while True: if self.state.blockindex == blockindex and self.currentBlock().localCursorIndex == localCursorIndex: #self.currentBlock() is only executed if the blockindex is already correct return True else: if not self.right(): #end of track raise RuntimeError("Position or block not found. Either the block has been deleted or is too short now.", (self.state.blockindex, self.currentBlock().localCursorIndex)) def toItemInBlock(self, targetItem, targetBlock): """A block is the only unique element in a Laborejo score. Items are unique per-block. So if we find the block we can find the correct item We start from head to make sure we parse the actual content of the block and not some confused leftovers after a content linked insert/delete. This was indeed the reason why this function was introduced.""" self.head() while True: if self.currentBlock() is targetBlock and self.currentItem() is targetItem: return True else: if not self.right(): #end of track raise RuntimeError("Item {} in block {} not found.".format(targetItem, targetBlock)) def currentItem(self): return self.currentBlock().currentItem() def previousItem(self): return self.currentBlock().previousItem() def nextItem(self): return self.currentBlock().nextItem() """ def lookaheadItems(self, number): #return a list of items, including the current one. #number=2 is the current and the next item. startItem = self.currentItem() startBlock = self.currentBlock() #this is the only unique thing we can rely on. result = [startItem] for i in range(number-1): self.right() result.append(self.currentItem()) self.toItemInBlock(startItem, startBlock) #find the correct cursor position and trackState assert len(result) == number return result """ #Never used, never tested. def insert(self, item): """We want the cursor to stay on the the same item (or appending) as before the insert. If there is a simple item insert the old item will be one position to the right. But with linked content blocks the step to the right gets multiplied and so the trackState counting goes wrong. Sometimes horribly. We need to reset. """ startItem = self.currentItem() startBlock = self.currentBlock() #this is the only unique thing we can rely on. startBlock.insert(item) self.toItemInBlock(startItem, startBlock) #find the correct cursor position and trackState return True def delete(self): """Delete stops at a block boundary. It returns None and nothing happens. Item stay the same, cursor stays the same""" startBlock = self.currentBlock() nextItem = self.nextItem() #we want to return to that item because all items "gravitate to the left" after deletion. Like like in a text editor. result = startBlock.delete() self.toItemInBlock(nextItem, startBlock) #find the correct cursor position and trackState return result def parseLeft(self): item = self.currentItem() dur = item.logicalDuration() self.state.tickindex -= dur #Metrical position self.state.ticksSinceLastMeasureStartLive -= dur #can become < 0, we crossed a measure-border left. if self.state.ticksSinceLastMeasureStartLive < 0 : self.state.ticksSinceLastMeasureStartLive = self.state.metricalInstruction().oneMeasureInTicks + self.state.ticksSinceLastMeasureStartLive if isinstance(item, Chord): pass elif isinstance(item, Rest): pass elif isinstance(item, LegatoSlur): self.state.duringLegatoSlur = not self.state.duringLegatoSlur #there is only level of slurs and it is not allowed to insert a legatoSlurOpen when self.state.duringLegatoSlur is True or ..close if the state is false. This makes just toggling the value possible, elif isinstance(item, MultiMeasureRest): pass elif isinstance(item, Clef): self.state.clefs.pop() elif isinstance(item, KeySignature): self.state.keySignatures.pop() elif isinstance(item, MetricalInstruction): self.state.metricalInstructions.pop() elif isinstance(item, DynamicSignature): self.state.dynamicSignatures.pop() elif isinstance(item, InstrumentChange): self.state.instrumentChanges.pop() #else: #items.Item def parseRight(self): item = self.previousItem() dur = item.logicalDuration() self.state.tickindex += dur #Anything that is right of the cursor (including the current item, which is technically right of the cursor as well) does not matter for the tickindex and is not saved in here. #Metrical position self.state.ticksSinceLastMeasureStartLive += dur tickRest = self.state.ticksSinceLastMeasureStartLive - self.state.metricalInstruction().oneMeasureInTicks if tickRest >= 0: #Measure Number calculation does not protect against human error. A user can add a MetricalInstruction at the beginning of a measure which will create problems here and there but is allowed nevertheless as temporary state while editing self.state.ticksSinceLastMeasureStartLive = tickRest #If one measure was overfull the rest ist chosen as next startpoint. if isinstance(item, Chord): pass elif isinstance(item, Rest): pass elif isinstance(item, LegatoSlur): self.state.duringLegatoSlur = not self.state.duringLegatoSlur #there is only level of slurs and it is not allowed to insert a legatoSlurOpen when self.state.duringLegatoSlur is True or ..close if the state is false. This makes just toggling the value possible, elif isinstance(item, MultiMeasureRest): pass elif isinstance(item, Clef): self.state.clefs.append(item) elif isinstance(item, KeySignature): self.state.keySignatures.append(item) elif isinstance(item, MetricalInstruction): self.state.metricalInstructions.append(item) elif isinstance(item, DynamicSignature): self.state.dynamicSignatures.append(item) self.state.dynamicRamp = None #reset elif isinstance(item, DynamicRamp): #This is only for a complete parse, not for cursor movement. That is why this is not stack and there is nothing in parseLeft() to unset this. head() is enough to unset. self.state.dynamicRamp = item elif isinstance(item, ChannelChange): self.state.midiChannels.append(item.value) elif isinstance(item, InstrumentChange): self.state.instrumentChanges.append(item) def newGraphTrackCC(self, cc): self.ccGraphTracks[cc] = GraphTrackCC(cc, parentTrack = self) def addExistingGraphTrackCC(self, cc, graphTrackObject): graphTrackObject.parentTrack = self #in case of undo this is redundant. In other cases it might be neccessary graphTrackObject.cc = cc #this makes this function the basis for "change cc" or even moving to other tracks self.ccGraphTracks[cc] = graphTrackObject def deleteGraphTrackCC(self, cc): result = self.ccGraphTracks[cc] self.ccGraphTracks[cc].prepareForDeletion() #this only deletes the current midi track. not the midi out. If we add this track again through undo we don't need to do anything. it will be regenerated automatically. del self.ccGraphTracks[cc] return result #we don't need to unregister anything from cbox. #Save / Load / Export def lilypond(self): """Called by score.lilypond(), returns a string. We carry a dict around to hold lilypond on/off markers like tuplets that need to set as ranges. This dict begins here. Each track gets its own. """ for item in self.blocks[0].data[:4]: if type(item) is MetricalInstruction: timeSig = "" break else: timeSig = "\\once \\override Staff.TimeSignature #'stencil = ##f \\cadenzaOn " carryLilypondRanges = {} #handed from item to item for ranges such as tuplets. Can act like a stack or simply remember stuff. upbeatLy = "\\partial {} ".format(Duration.createByGuessing(self.upbeatInTicks).lilypond(carryLilypondRanges)) if self.upbeatInTicks else "" data = " ".join(block.lilypond(carryLilypondRanges) for block in self.blocks) if data: return timeSig + upbeatLy + data else: return "" def serialize(self)->dict: return { "sequencerInterface" : self.sequencerInterface.serialize(), "blocks" : [block.serialize() for block in self.blocks], "ccGraphTracks" : {ccNumber:graphTrackCC.serialize() for ccNumber, graphTrackCC in self.ccGraphTracks.items()}, "durationSettingsSignature" : self.durationSettingsSignature.serialize(), "dynamicSettingsSignature" : self.dynamicSettingsSignature.serialize(), "double" : self.double, "initialMidiChannel" : self.initialMidiChannel, "initialMidiProgram" : self.initialMidiProgram, "initialMidiBankMsb" : self.initialMidiBankMsb, "initialMidiBankLsb" : self.initialMidiBankLsb, "ccChannels" : self.ccChannels, "midiTranspose" : self.midiTranspose, "initialInstrumentName" : self.initialInstrumentName, "initialShortInstrumentName" : self.initialShortInstrumentName, "upbeatInTicks" : self.upbeatInTicks, } @classmethod def instanceFromSerializedData(cls, parentData, serializedData): self = cls.__new__(cls) self.parentData = parentData self.sequencerInterface = template.engine.sequencer.SequencerInterface.instanceFromSerializedData(self, serializedData["sequencerInterface"]) self.upbeatInTicks = int(serializedData["upbeatInTicks"]) self.blocks = [Block.instanceFromSerializedData(block, parentObject = self) for block in serializedData["blocks"]] self.durationSettingsSignature = DurationSettingsSignature.instanceFromSerializedData(serializedData["durationSettingsSignature"], parentObject = self) self.dynamicSettingsSignature = DynamicSettingsSignature.instanceFromSerializedData(serializedData["dynamicSettingsSignature"], parentObject = self) self.double = serializedData["double"] self.initialMidiChannel = serializedData["initialMidiChannel"] self.initialMidiProgram = serializedData["initialMidiProgram"] self.initialMidiBankMsb = serializedData["initialMidiBankMsb"] self.initialMidiBankLsb = serializedData["initialMidiBankLsb"] self.ccGraphTracks = {int(ccNumber):GraphTrackCC.instanceFromSerializedData(graphTrackCC, parentTrack = self) for ccNumber, graphTrackCC in serializedData["ccGraphTracks"].items()} self.ccChannels = serializedData["ccChannels"] self.midiTranspose = serializedData["midiTranspose"] self.initialInstrumentName = serializedData["initialInstrumentName"] self.initialShortInstrumentName = serializedData["initialShortInstrumentName"] self._processAfterInit() return self def staticBlocksRepresentation(self): """Only the blocks""" result = [] tickindex = 0 for block in self.blocks: duration = block.duration() d = { "id" : id(block), "name" : block.name, "tickindex" : tickindex, #"data" : block, "completeDuration" : duration, "minimumInTicks" : block.minimumInTicks, } result.append(d) tickindex += duration return result def staticTrackRepresentation(self)->dict: """Only the minimal track data itself, no items. Can be used to re-order tracks or keep the number of tracks in sync.""" result = { "id" : id(self), "name" : self.name, "index" : self.state.index(), "upbeatInTicks": int(self.upbeatInTicks), "double" : self.double, "audible" : self.sequencerInterface.enabled, "initialMidiChannel" : self.initialMidiChannel, "initialMidiProgram" : self.initialMidiProgram, "initialMidiBankMsb" : self.initialMidiBankMsb, "initialMidiBankLsb" : self.initialMidiBankLsb, "ccChannels" : self.ccChannels, "midiTranspose" : self.midiTranspose, "initialInstrumentName" : self.initialInstrumentName, "initialShortInstrumentName" : self.initialShortInstrumentName, #DurationSettings, all strings "duration.defaultOn" : str(self.durationSettingsSignature.defaultOn), "duration.defaultOff" : str(self.durationSettingsSignature.defaultOff), "duration.staccatoOn" : str(self.durationSettingsSignature.staccatoOn), "duration.staccatoOff" : str(self.durationSettingsSignature.staccatoOff), "duration.tenutoOn" : str(self.durationSettingsSignature.tenutoOn), "duration.tenutoOff" : str(self.durationSettingsSignature.tenutoOff), "duration.legatoOn" : str(self.durationSettingsSignature.legatoOn), "duration.legatoOff" : str(self.durationSettingsSignature.legatoOff), } for key, value in self.dynamicSettingsSignature.dynamics.items(): #keys are all strings. we prefix with dynamics for clarity result["dynamics." + key] = value return result def export(self)->dict: return { "sequencerInterface" : self.sequencerInterface.export(), } def getPreliminaryData(self): """Parse the track and gather information: All Dynamic Signatures All Dynamic Ramps. This data will be used in self.staticRepresentation. """ self.head() localRight = self.right #performance localPreviousItem = self.previousItem #performance localType = type #performance lastDynamicRamp = None countBlockIndex = 0 #Parse from left to right. Remember that the item we are interested in is always the previous item! while localRight(): if self.state.blockindex > countBlockIndex: assert self.state.blockindex == countBlockIndex+1 #we crossed a block boundary. previousItem() will fail because it assumes we do not call it on a block beginning. countBlockIndex += 1 continue item = localPreviousItem() itemtype = localType(item) if itemtype is DynamicRamp: lastDynamicRamp = item item._cachedTickIndex = self.state.tickindex item._cachedVelocity = self.dynamicSettingsSignature.dynamics[self.state.dynamicSignature().keyword] elif itemtype is DynamicSignature and lastDynamicRamp: # A dynamic signature directly after a DynamicRamp. This is what we are looking for. lastDynamicRamp._cachedTargetVelocity = self.dynamicSettingsSignature.dynamics[item.keyword] lastDynamicRamp._cachedTargetTick = self.state.tickindex lastDynamicRamp = None def staticRepresentation(self): """The basis for all representations. Be it a GUI or playback. Representation does NOT mean export. Lilypond Export works in a different way. Not for saving, which is called serialize. One special case is that it takes block minimumTicks into account. It also generates the metronome for this track, which is not included in the export dict For the changes to take effect a GUI must react to them and calfbox must be updated. This is done on the API level by special functions and callbacks. """ #TODO: this is a hack. This makes Laborejo dependent on the api beeing used. This is only the case with GUI or Midi but not as a script. However, we need to expand the tempo track constantly. #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. midiNotesBinaryCboxData = bytes() # Create a binary blob that contains the MIDI events instrumentChangesBinaryCboxData = bytes() # same as above, but only for instrument changes. originalPosition = self.state.position() self.getPreliminaryData() barlines = OrderedDict() #tick:metricalInstruction . We do not use selft.state.barlines, which is simply barlines for the live cursor. This is to send Barlines to a UI and also to generate the Metronome by associating a metricalInstruction with each barline. result = [] metaData = {} #this will be the last item in the representation and can be popped by a GUI. It holds information like the position of barlines. #if self.hasContentLinks(): self.head() #reset the state #At this point there is NOTHING in the track state except the init data. You can't look up anything in the state until the cursor moved at least once right. That means a metrical instruction is impossible to detect through the trackstate right now. #Initial Midi values initialProgamChange = self.state.instrumentChange() assert self.state.midiChannel() == self.initialMidiChannel assert self.initialMidiProgram == initialProgamChange.program assert self.initialMidiBankMsb == initialProgamChange.msb assert self.initialMidiBankLsb == initialProgamChange.lsb if initialProgamChange.program >= 0: #-1 is off. instrumentChangesBinaryCboxData += cbox.Pattern.serialize_event(0, 0xC0 + self.initialMidiChannel, initialProgamChange.program, 0) instrumentChangesBinaryCboxData += cbox.Pattern.serialize_event(0, 0xB0 + self.initialMidiChannel, 0, self.initialMidiBankMsb) #position, status byte+channel, controller number, controller value instrumentChangesBinaryCboxData += 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 _allExportedChords = [] #a shortcuts for easier beam creation _allExportedChordsAppend = _allExportedChords.append #Process the items, most likely notes. Remember that the item we are interested in is the previous item because it is left of our cursor. previousBarlineTickIndex = self.upbeatInTicks previousItem = None while True: r = localRight() if r == 1: #Exporting the items and adding them to the result is completely done in this block. expObj is not used anywhere else in this function. previousItem = self.previousItem() expObj = previousItem.exportObject(self.state) #yes, it is correct that the state in the parameter is ahead by one position. Why is it that way? Because everything that matters, like new dynamics will only be parsed afterwards. The trackState is always correct except for the tickindex when exporting after parsing. Thats why exportObject sometimes substracts its own duration for finding its starting tick. resultAppend(expObj) dur = expObj["completeDuration"] if expObj["type"] != "InstrumentChange": for blob in expObj["midiBytes"]: #a list of midiNotesBinaryCboxData += blob if expObj["type"] == "Chord" or expObj["type"] == "Rest": #save for later when we create Beams. No other use. _allExportedChordsAppend(expObj) else: for blob in expObj["midiBytes"]: #a list of instrumentChangesBinaryCboxData += blob elif r == 2: #block end. Again, this is already the previous state. right now we are in the next block already. lastBlock = self.blocks[self.state.blockindex-1] #why -1? see comment above dur = lastBlock.staticExportEndMarkerDuration() resultAppend(BlockEndMarker().exportObject(self.state)) #this instance of BlockEndMarker does only exist during this export. else: break #Check if any new Barlines need to get created. It is possible that since the last item more than one barline needs to get created (Multi Measure Rests) #Achtung! Barlines are calculated by completed measure. That means it counts until a measure is full, according to the current metrical instruction. #But metrical instructions themselves need to be calculated at the beginning of the measure. To put them in sync we need to keep track. Therefore we use an ordered dict which provides pairing as well as order. #Additional benefit is that multiple metrical instructions on the same tick can be handled, eventhough we consider them a user error (according to the users manual) if type(previousItem) is MetricalInstruction and not(self.state.tickindex == 0 and self.upbeatInTicks) : #always start a new metrical section with a barline. Otherwise we don't see a barlines after a long section without barlines. previousBarlineTickIndex = self.state.tickindex barlines[self.state.tickindex] = self.state.metricalInstruction() else: #There is a distinction between metrical and non-metrical instructions. However, that doesn't matter for barlines. Both produce barlines. #Empty Metrical Instructions (the default for a new track) disable barlines until a metrical instruction appears which then (re)starts the metrical cycles. if dur and self.state.metricalInstruction().oneMeasureInTicks > 0: self.state.ticksSinceLastMeasureStart += dur tickRest = self.state.ticksSinceLastMeasureStart - self.state.metricalInstruction().oneMeasureInTicks while tickRest >= self.upbeatInTicks: #we crossed into the next measure. Or even more measures, for MMRests. By definition the metricalInstruction cannot have changed in the meantime since every tick was occupied by a sinle tick. barlines[previousBarlineTickIndex] = self.state.metricalInstruction() previousBarlineTickIndex = self.state.tickindex - tickRest + self.upbeatInTicks self.state.ticksSinceLastMeasureStart = tickRest #If one measure was overfull the rest ist chosen as next startpoint. tickRest = self.state.ticksSinceLastMeasureStart - self.state.metricalInstruction().oneMeasureInTicks #Loop Over #In the end add one final barline if the last measure was complete. This will create the effect, for the GUI and the user, that a measure "closes" once it is complete. There is no chance for a double barline because we use a dict. if barlines: barlines[previousBarlineTickIndex] = self.state.metricalInstruction() #We are now at the end the the track. ######### #Converting laborejo objects to export dicts is done. From here on everything uses the exported data ######### #Metronome start #The metronome cannot be calculated by simply looking at metricalInstructions. #We need to look at the barlines. Two instructions in a row at the same tick are wrong, #but technically possible. These are not two full measures of metronome self.asMetronomeData = tuple((pos, m.isMetrical, m.treeOfInstructions) for pos, m in barlines.items()) #Metronome end. Nothing below is connected with the metronome subtrack. #Calculate the beam positions for the static groups. #We send this data to the GUI. beamGroups = [] #a list of list of exportChords. currentlyDuringBeaming = False if _allExportedChords: lastExpChord = _allExportedChords[-1] #for performance for expChord in _allExportedChords: if "beamGroup" in expChord and expChord["beamGroup"]: if currentlyDuringBeaming: #switch it off then expChord["beamGroup"] = "close" #for the GUI currentlyDuringBeaming = False beamGroups[-1].append(expChord) else: expChord["beamGroup"] = "open" #for the GUI if expChord is lastExpChord: continue currentlyDuringBeaming = True beamGroups.append(list()) beamGroups[-1].append(expChord) elif currentlyDuringBeaming: if expChord is lastExpChord or expChord["baseDuration"] > D8: #invalid beaming. An open beaming group encountering either the end of the track or >D8 is invalid and not counted. currentlyDuringBeaming = False beamGroups.pop() #the group so far was invalid because it was not closed properly. else: beamGroups[-1].append(expChord) resultBeamGroups = [] #will be added to the exported meta-data for beamGroup in beamGroups: if beamGroup: #with content #stem means in staffline coordinates (dots on lines): (starting point, length, 1|-)] 1 stem is on the right or -1 left side of the note. #We don't deal with the length, stem[1], here. By the time writing this function it was just constant "5" anyway #min and max below are "reversed" (min for the highest position in upward stems) because we set coordinates in lines and rows with the middle line as origin/0. Important: Stems do not begin at the same line/space as their note. Upstems begin notehead-1, downstems notehead+1 stems = [o["stem"] for o in beamGroup] #this cannot be a generator because we are using it multiple times. assert stems minimum = min(s[0] for s in stems) maximum = max(s[0] for s in stems) if sum(s[2] for s in stems) >= 0: #beam upwards direction direction = 1 length = -5 beamPosition = minimum + length - 1 #if not -1 it will be the same as the highest note, in wide intervals startDotOnLineKeyword = "lowestPitchAsDotOnLine" #assert beamPosition <= 0 else: #beam down direction length = 5 beamPosition = maximum + length + 2 #if not +2 it will be in the position of lowest note, in wide intervals direction = -1 startDotOnLineKeyword = "highestPitchAsDotOnLine" #assert beamPosition > 0 #beams have the same syntax and structure as a stem. #Until now we figured out the position of the beams as well as their length. However: Mixed duration (e.g. 8th + 16th) groups are allowed and also common. We need to split these groups into sub-groups #These groups are different than the real groups because they share the same baseline for the beams and also have at least one uninterrupted beam connecting all sub-groups. subBeamGroups = [[]] currentSubGroupFlag = beamGroup[0]["flag"] for obj in beamGroup: if obj["flag"] == currentSubGroupFlag: subBeamGroups[-1].append(obj) else: subBeamGroups.append(list()) currentSubGroupFlag = obj["flag"] subBeamGroups[-1].append(obj) for subBeamGroup in subBeamGroups: for obj in subBeamGroup: startStaffLine = obj[startDotOnLineKeyword] length = beamPosition - startStaffLine obj["beam"] = (startStaffLine, length, direction) #(starting point, length, 1|-)] 1 stem is on the right or -1 left side of the note. firstItem, lastItem = subBeamGroup[0], subBeamGroup[-1] connectorLength = lastItem["completeDuration"] if not lastItem == beamGroup[-1] else 0 resultBeamGroups.append((firstItem["tickindex"], lastItem["tickindex"] + connectorLength, abs(firstItem["flag"]), beamPosition, direction)) #tick-start, tick-end, duration-type, position as staffline. Extra simple for the GUI. #Beams finished resultAppend(metaData) metaData["barlines"] = barlines.keys() 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 #Notes t = (midiNotesBinaryCboxData, 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 #Instrument Changes self.sequencerInterface.setSubtrack(key="instrumentChanges", blobs=[(instrumentChangesBinaryCboxData, 0, self.state.tickindex),]) #(bytes-blob, position, length) self.toPosition(originalPosition, strict = False) #has head() in it return result #Dependency Injections. template.engine.sequencer.Score.TrackClass = Track #Score will look for Track in its module.