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.

1296 lines
64 KiB

4 years ago
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
11 months ago
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
4 years ago
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
4 years ago
Laborejo2 is free software: you can redistribute it and/or modify
4 years ago
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")
4 years ago
#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
4 years ago
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"
4 years ago
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."""
defaultDynamicSignature = DynamicSignature("custom") #This is the start dynamic value like 'forte', not the DynamicSettingsSignature
4 years ago
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 = [track.initialKeySignature]
self.clefs = [Clef(track.initialClefKeyword)]
self.metricalInstructions = [track.initialMetricalInstruction] #no barlines, no metrical information.
4 years ago
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.
4 years ago
self.instrumentChanges = [InstrumentChange(track.initialMidiProgram, track.initialMidiBankMsb, track.initialMidiBankLsb, track.initialShortInstrumentName, )]
4 years ago
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
4 years ago
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.
4 years ago
#def __repr__(self) -> str:
# return f"Laborejo Track: {self.sequencerInterface.name}"
4 years ago
def __init__(self, parentData, name=None):
self.parentData = parentData
self.sequencerInterface = template.engine.sequencer.SequencerInterface(parentTrack=self, name=name)
4 years ago
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.
4 years ago
self.blocks = [Block(track = self)]
4 years ago
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.
4 years ago
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.
4 years ago
#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
4 years ago
self.midiTranspose = 0 # -127 to +127 but the result is never outside of 0-127.1 Cannot change during the track.
4 years ago
#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"
#Since version 2.1.0 the initial signature can be set by the user, mostly as a GUI convenience.
#These are used by the track-state init/head
self.initialClefKeyword:str = "treble" #it is much easier to handle the keyword with save/load and setting this value, so we are not using the item.Clef class
self.initialKeySignature = KeySignature(20, [0,0,0,0,0,0,0]) #C Major
self.initialMetricalInstruction = MetricalInstruction(tuple(), isMetrical = False)
4 years ago
self.asMetronomeData = None #This track as metronome version. Is always up to date through export.
4 years ago
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
4 years ago
@property
def name(self):
return self.sequencerInterface.name
@name.setter
def name(self, newValue):
self.sequencerInterface.name = newValue
4 years ago
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
4 years ago
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
4 years ago
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() #we parse the previous item
4 years ago
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
4 years ago
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
4 years ago
while self.right():
if self.state.tickindex >= goalInTicks:
break
def measureStart(self):
if self.state.ticksSinceLastMeasureStartLive == 0:
#Already at the start
return
elif self.state.ticksSinceLastMeasureStartLive < 0: #upbeats are lower than 0
4 years ago
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
4 years ago
self.right()
break
else:
self.head()
assert self.state.ticksSinceLastMeasureStartLive == 0, self.state.ticksSinceLastMeasureStartLive
4 years ago
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.
"""
4 years ago
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)
self.state.ticksSinceLastMeasureStartLive = 0 #by definition.
4 years ago
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.
4 years ago
#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. Also for slurs and dynamics, that are lilypond postfixes
but appear *before* the time in the output. Which is different than in Laborejo where
the items are where they should be.
This dict begins here. Each track gets its own.
"""
carryLilypondRanges = {} #handed from item to item for ranges such as tuplets. Can act like a stack or simply remember stuff.
#Initial Metrical Instruction
4 years ago
for item in self.blocks[0].data[:4]:
if type(item) is MetricalInstruction: #don't use the initial. use the user provided one.
4 years ago
timeSig = ""
break
else:
if self.initialMetricalInstruction.treeOfInstructions:
timeSig = self.initialMetricalInstruction.lilypond(carryLilypondRanges) + "\n"
else:
timeSig = "\\once \\override Staff.TimeSignature #'stencil = ##f \\cadenzaOn\n"
4 years ago
#Initial Clef
for item in self.blocks[0].data[:4]:
if type(item) is Clef: #don't use the initial. use the user provided one.
clef = ""
break
else:
clef = "\\clef " + self.initialClefKeyword + "\n" #internal clef keywords are the same as lilypond
#Initial Key Signature
for item in self.blocks[0].data[:4]:
if type(item) is KeySignature: #don't use the initial. use the user provided one.
keySignature = ""
break
else:
keySignature = self.initialKeySignature.lilypond(carryLilypondRanges) + "\n"
upbeatLy = "\\partial {} ".format(Duration.createByGuessing(self.upbeatInTicks).lilypond(carryLilypondRanges)) if self.upbeatInTicks else ""
6 months ago
#Find sequential content-linked blocks to convert them into lilypond voltas
last = set()
currentFirstBlockInARow = None
repeatCounter = 0
blockRepeatIndex = {} #block : int
blockRepeatTotal = {} #first block : int. This is not the same as len(set(block.linkedContentBlocksInScore())) because the latter is all links in total, while we only want the consecutive count. Only the first block of a row is in here.
#TODO: Contentlink -> Volta conversion is currently deactivated for multi-track. Too many lilypond problems
#As long as blockRepeatTotal exists and is empty export will treat each block as standalone, so everything works
#The commented-out code does not show the number of repeats, and more importantly, places the same repeats in all staffs, even if they do not exist there.
#This is not compatible with real-life music where one instrument plays the same part twice, but the second one has a different version the 2nd time.
#If we ever find a lilypond way to only set synchronized repeats this can get activated for more tracks.
codeActivated = len(self.parentData.tracks) == 1
codeActivated = False #TODO: NO! Too fragile
for block in self.blocks: #in order
links = set(block.linkedContentBlocksInScore()) #it's a generator
if links:
if codeActivated and links == last:
#This is not the first one in a row.
repeatCounter += 1
blockRepeatTotal[currentFirstBlockInARow] += 1
else:
#This is the first one in a row. Reset.
repeatCounter = 0
currentFirstBlockInARow = block
blockRepeatTotal[currentFirstBlockInARow] = 1
last = links
blockRepeatIndex[block] = repeatCounter #0 for standalone blocks. Each block one entry
#Another round through the blocks to generate data
lyData = ""
for block in self.blocks:
if block in blockRepeatTotal:
l = blockRepeatTotal[block]
if l > 1:
assert blockRepeatIndex[block] == 0
lyData += f"\n \\repeat volta {l}" + block.lilypond(carryLilypondRanges) #the lilypond block includes music expression { }
else:
#No voltas. We could do \repeat volta 1 but that would be ugly lilypond.
lyData += block.lilypond(carryLilypondRanges)
else:
# A linked block in a row.
# do NOT export any content linked block that is not the first in a row.
# they are included as voltas above.
pass
6 months ago
if lyData:
return clef + keySignature + timeSig + upbeatLy + lyData + "\n"
4 years ago
else:
6 months ago
return "" #Empty track
4 years ago
def serialize(self)->dict:
return {
4 years ago
"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(),
4 years ago
"double" : self.double,
"initialClefKeyword" : self.initialClefKeyword,
"initialKeySignature" : self.initialKeySignature.serialize(),
"initialMetricalInstruction" : self.initialMetricalInstruction.serialize(),
4 years ago
"initialMidiChannel" : self.initialMidiChannel,
"initialMidiProgram" : self.initialMidiProgram,
"initialMidiBankMsb" : self.initialMidiBankMsb,
"initialMidiBankLsb" : self.initialMidiBankLsb,
"ccChannels" : self.ccChannels,
"midiTranspose" : self.midiTranspose,
"initialInstrumentName" : self.initialInstrumentName,
"initialShortInstrumentName" : self.initialShortInstrumentName,
4 years ago
"upbeatInTicks" : self.upbeatInTicks,
}
4 years ago
@classmethod
def instanceFromSerializedData(cls, parentData, serializedData):
4 years ago
self = cls.__new__(cls)
self.parentData = parentData
self.sequencerInterface = template.engine.sequencer.SequencerInterface.instanceFromSerializedData(self, serializedData["sequencerInterface"])
4 years ago
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"]
4 years ago
self.initialMidiChannel = serializedData["initialMidiChannel"]
self.initialMidiProgram = serializedData["initialMidiProgram"]
self.initialMidiBankMsb = serializedData["initialMidiBankMsb"]
self.initialMidiBankLsb = serializedData["initialMidiBankLsb"]
4 years ago
self.ccGraphTracks = {int(ccNumber):GraphTrackCC.instanceFromSerializedData(graphTrackCC, parentTrack = self) for ccNumber, graphTrackCC in serializedData["ccGraphTracks"].items()}
4 years ago
self.ccChannels = serializedData["ccChannels"]
self.midiTranspose = serializedData["midiTranspose"]
self.initialInstrumentName = serializedData["initialInstrumentName"]
self.initialShortInstrumentName = serializedData["initialShortInstrumentName"]
#Version 2.1.0
if "initialClefKeyword" in serializedData:
self.initialClefKeyword = serializedData["initialClefKeyword"]
else:
self.initialClefKeyword = "treble"
if "initialKeySignature" in serializedData:
self.initialKeySignature = KeySignature.instanceFromSerializedData(serializedData["initialKeySignature"], parentObject=self)
else:
self.initialKeySignature = KeySignature(20, [0,0,0,0,0,0,0]) #C Major
if "initialMetricalInstruction" in serializedData:
self.initialMetricalInstruction = MetricalInstruction.instanceFromSerializedData(serializedData["initialMetricalInstruction"], parentObject=self)
else:
self.initialMetricalInstruction = MetricalInstruction(tuple(), isMetrical = False)
4 years ago
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,
}