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.
1354 lines
67 KiB
1354 lines
67 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."""
|
|
|
|
defaultDynamicSignature = DynamicSignature("custom") #This is the start dynamic value like 'forte', not the DynamicSettingsSignature
|
|
|
|
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.
|
|
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"
|
|
|
|
#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)
|
|
|
|
self.asMetronomeData = None #This track as metronome version. Is always up to date through export.
|
|
|
|
#Version 2.2.0
|
|
self.audible = True #requires main.calculatedMuteSoloForCbox after changing and after load
|
|
self.solo = False #requires main.calculatedMuteSoloForCbox after changing and after load
|
|
|
|
self._processAfterInit()
|
|
|
|
def _processAfterInit(self):
|
|
"""Call this after either init or instanceFromSerializedData
|
|
Mute and Solo evaluation is in main.
|
|
It's not included here because it need an overview of all tracks."""
|
|
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 duplicateToReservedSpaceBlock(self, block):
|
|
"""We actually do a copy and then remove the content.
|
|
While at the moment of creation (v2.2.0) it would be equally well to create a new empty
|
|
block and set the duration and name this way is better for future functionality."""
|
|
originalPosition = self.state.position()
|
|
index = self.blocks.index(block)
|
|
copy = block.copy(newParentTrack=self, nameSuffix="-reserved")
|
|
|
|
copy.data = list() #empty
|
|
copy.minimumInTicks = block.duration()
|
|
|
|
self.blocks.insert(index +1, copy)
|
|
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
|
|
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:
|
|
#Already at the start
|
|
return
|
|
elif 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()
|
|
|
|
assert self.state.ticksSinceLastMeasureStartLive == 0, self.state.ticksSinceLastMeasureStartLive
|
|
|
|
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)
|
|
self.state.ticksSinceLastMeasureStartLive = 0 #by definition.
|
|
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.
|
|
|
|
|
|
def zeroLogicalDuration(self)->bool:
|
|
"""Return true if this has no actual items with duration in it.
|
|
A track with only text items and barlines is empty.
|
|
Minimum tick duration does not affect isEmpty"""
|
|
for block in self.blocks:
|
|
if not block.zeroLogicalDuration():
|
|
return False
|
|
return True
|
|
|
|
#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
|
|
for item in self.blocks[0].data[:4]:
|
|
if type(item) is MetricalInstruction: #don't use the initial. use the user provided one.
|
|
timeSig = ""
|
|
break
|
|
else:
|
|
if self.initialMetricalInstruction.treeOfInstructions:
|
|
timeSig = self.initialMetricalInstruction.lilypond(carryLilypondRanges) + "\n"
|
|
else:
|
|
timeSig = "\\once \\override Staff.TimeSignature #'stencil = ##f \\cadenzaOn\n"
|
|
|
|
#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)
|
|
|
|
upbeatLy = "\\partial {} ".format(Duration.createByGuessing(self.upbeatInTicks).lilypond(carryLilypondRanges)) if self.upbeatInTicks else ""
|
|
|
|
#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
|
|
|
|
if lyData:
|
|
return clef + keySignature + timeSig + upbeatLy + lyData + "\n"
|
|
else:
|
|
return "" #Empty track
|
|
|
|
|
|
def serialize(self)->dict:
|
|
return {
|
|
"sequencerInterface" : self.sequencerInterface.serialize(), #this saves the actual cbox.enabled value. But that is harmless, first because the state is actually valid, second because we recalculate mute/solo after load anyway.
|
|
"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,
|
|
"initialClefKeyword" : self.initialClefKeyword,
|
|
"initialKeySignature" : self.initialKeySignature.serialize(),
|
|
"initialMetricalInstruction" : self.initialMetricalInstruction.serialize(),
|
|
"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,
|
|
|
|
#2.2.0
|
|
"audible" : self.audible, #bool
|
|
"solo" : self.solo, #bool
|
|
}
|
|
|
|
|
|
|
|
@classmethod
|
|
def instanceFromSerializedData(cls, parentData, serializedData):
|
|
self = cls.__new__(cls)
|
|
self.parentData = parentData
|
|
self.sequencerInterface = template.engine.sequencer.SequencerInterface.instanceFromSerializedData(self, serializedData["sequencerInterface"]) #this loads the actual cbox.enabled value. But that is harmless, first because the state is actually valid, second because we recalculate mute/solo after load anyway.
|
|
|
|
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"]
|
|
|
|
#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)
|
|
|
|
#2.2.0
|
|
if "audible" in serializedData:
|
|
self.audible = serializedData["audible"] #bool
|
|
else:
|
|
self.audible = True
|
|
|
|
if "solo" in serializedData:
|
|
self.solo = serializedData["solo"] #bool
|
|
else:
|
|
self.solo = False
|
|
|
|
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.audible,
|
|
"solo" : self.solo,
|
|
"initialClefKeyword" : self.initialClefKeyword,
|
|
"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)
|
|
|
|
|
|
previous_subGroup_firstNoteDuration = 0
|
|
previous_subGroup_lastNoteDuration = 0
|
|
|
|
for subBeamGroup in subBeamGroups:
|
|
for obj in subBeamGroup:
|
|
startStaffLine = obj[startDotOnLineKeyword]
|
|
length = beamPosition - startStaffLine #vertical, in stafflines.
|
|
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]
|
|
|
|
#Now calculate the horizontal length.
|
|
#This is difficult because you can mix durations e.g. 16th and 8th in the same beam.
|
|
#This syncopation 16 8 16 has the 8th in the middle. If any of the 16th beams connects to the left or right side this would be indistinguishable from a 16th note.
|
|
#It is also difficult because it is on the boundary of subBeamGroups
|
|
|
|
if not lastItem == beamGroup[-1]:
|
|
connectorLength = lastItem["completeDuration"]
|
|
elif lastItem == beamGroup[-1]:
|
|
connectorLength = 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
|
|
metaData["initialClef"] = self.initialClefKeyword #it is already a string
|
|
#the state is at the end, but it doesn't matter for the init-sigs. It will show the wrong tick index, but don't worry.
|
|
metaData["initialKeySignature"] = self.initialKeySignature.exportObject(self.state)
|
|
metaData["initialMetricalInstruction"] = self.initialMetricalInstruction.exportObject(self.state)
|
|
|
|
#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.
|
|
|