You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1190 lines
58 KiB

#! /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 <http://www.gnu.org/licenses/>.
"""
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 blockName(self):
return self.track.currentBlock().name
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.
This includes the auto-generated block boundaries, which makes this function easy.
"""
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\n"
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 + "\n"
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.