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.
 
 

1123 lines
60 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")
#Standar Library
import sys
#3rd Party
#Template
import template.engine.sequencer
#Our modules
from .block import Block
from .track import Track
from .cursor import Cursor
from .tempotrack import TempoTrack
from .lilypond import fromTemplate
class Data(template.engine.sequencer.Score):
def __init__(self, parentSession):
super().__init__(parentSession)
self.tracks = [Track(parentData = self)] #this is the track order and all visible tracks. For getting a specific track use Track.allTracks with a track id.
self.hiddenTracks = {} #track-instance:original Position. The value can exist multiple times. These still create a playback representation but are read-only and do not react to editing or GUI requests because you can't access them except through Track.allTrack (which is exactly the same for deleted tracks). Hidden tracks are saved though, deleted ones not.
self.tempoTrack = TempoTrack(parentData = self) #The tempoTrack is a Laborejo class. TempoMap is a template.sequencer class that is used by TempoTrack internally
#Lilypond Properties that might be used elsewhere as well, e.g. JACK Pretty Names
self.metaData = {key:"" for key in ("title", "subtitle", "dedication","composer","subsubtitle","instrument","meter","arranger", "poet","piece","opus","copyright","tagline", "subtext")}
self.metaData["metronome"] = True #show metronome in printout? v2.1.0
self.metaData["transposition"] = "c c" # Whole score transposition for Lilypond. v2.2.0
self.metaData["template-file"] = "" # Lilypond Template file. If empty use default.ly . v2.2.0
self.currentMetronomeTrack = self.tracks[0] #A Laborejo Track, indepedent of currentTrack. The metronome is in self.metronome, set by the template Score.
self._processAfterInit()
def _processAfterInit(self):
self.cursor = Cursor(self)
self.trackIndex = 0 #a temp variable.
self.cursorWhenSelectionStarted = None #A cursor dict, from self.cursorExport(). Is None when there is no selection. Can be used for "if selection:" questions. Gets changed quite often.
self.copyObjectsBuffer = [] #for copy and paste. obviously empty after load file. Also not saved.
self.cachedTrackDurations = {} #updated after every track export
#track.asMetronomeData is a generated value from staticExport. Not available yet. needs to be done in api.startEngine #self.metronome.generate(data=self.currentMetronomeTrack.asMetronomeData, label=self.currentMetronomeTrack.name)
def duration(self):
"""Return the duration of the whole score, in ticks"""
#TODO: use cached duration? How often is this used? Pretty often. 3 Times for a single track note update.
#TODO: Measure before trying to improve performance.
result = []
for track in self.tracks:
result.append(track.duration())
return max(result)
def currentTrack(self):
tr = self.tracks[self.trackIndex]
return tr
def trackById(self, trackId):
"""Also looks up hidden and deleted tracks"""
try:
ret = Track.allTracks[trackId]
except:
print ("Error while trying to get track by id")
print (sys.exc_info()[0])
print (trackId, hex(trackId), Track.allTracks, self.tracks, [(track.name, id(track)) for track in self.tracks], self.hiddenTracks, self.trackIndex)
raise
return ret
def noteById(self, noteId):
"""A note, and its parent Chord/Item, can be in multiple tracks.
But only ever in one block"""
return Note.allNotes[noteId].parentChord.parentTracks, Note.allNotes[noteId] #this returns the actual instances, not the weak references.
#def itemById(self, itemId):
# """An item can be in multiple tracks. Return all those"""
# return self.allItems[itemId].parentTracks, self.allItems[itemId] #this returns the actual instances, not the weak references.
def allBlocks(self):
"""return all blocks in all tracks in order from top left
to bottom right, thinking in score-layout."""
for track in self.tracks + list(self.hiddenTracks.keys()):
for block in track.blocks:
yield block
def blockById(self, blockId):
"""A block (not it's content!) is unique. There is
exactly one instance with this id, in one track"""
return Block.allBlocks[blockId].parentTrack, Block.allBlocks[blockId] #returns the actual instances, not the weak references.
def currentTrackId(self):
trId = id(self.currentTrack())
return trId
def currentBlock(self):
"""There is always a block, this is guaranteed"""
return self.currentTrack().currentBlock()
def currentItem(self):
return self.currentTrack().currentItem()
#def currentItemAndItsTrack(self):
# curItem = self.currentItem()
# if curItem:
# return curItem.currentItem.parentTracks, curItem
# else:
# return None
def currentContentLinkAndItsBlocksInAllTracks(self):
curBlock = self.currentBlock()
trackList = list(set([block.parentTrack for block in curBlock.linkedContentBlocksInScore()])) #for this list(set( it doesn't matter that the trackList will not be in order.
return trackList, curBlock
def insertTrack(self, atIndex, trackObject):
"""This is also newTrack. the api creates a track object for that.
atIndex inserts the track before the item currently
in this position. If atIndex is bigger than the highest current
index it will be appended through standard python behaviour
We don't use the template-function addTrack.
"""
assert trackObject.sequencerInterface
assert trackObject.parentData == self
self.tracks.insert(atIndex, trackObject)
self.trackIndex = self.tracks.index(trackObject)
assert atIndex == self.trackIndex
def tracksAsDict(self):
"""used by rearrangeTracks
Key is the track id, value the track instance"""
result = {}
for track in self.tracks:
result[id(track)] = track
return result
def asListOfTrackIds(self):
"""Return an ordered list of track ids"""
result = []
for track in self.tracks:
result.append(id(track))
return result
def rearrangeTracks(self, listOfTrackIds):
"""Reorder the tracks.
Achtung! Not including a track will delete this track. This is
not the right place to delete a track. So we check for that case"""
startTrack = self.currentTrack() #we will return later to this track
originalLength = len(self.tracks)
tracksDict = self.tracksAsDict()
newArrangement = []
for idLong in listOfTrackIds:
newArrangement.append(tracksDict[idLong])
seen = set()
seen_add = seen.add
self.tracks = [ x for x in newArrangement if x not in seen and not seen_add(x)]
assert originalLength == len(self.tracks)
assert self.tracks
self.trackIndex = self.tracks.index(startTrack)
def deleteCurrentTrack(self):
if len(self.tracks) > 1:
nowInTicks = self.currentTrack().state.tickindex
result = self.currentTrack(), self.trackIndex #return to the API for undo.
super().deleteTrack(self.currentTrack())
#self.currentTrack().prepareForDeletion() #remove calfbox objects
#self.tracks.remove(self.currentTrack())
if self.trackIndex+1 > len(self.tracks): #trackIndex from 0, len from 1.
self.trackIndex -= 1
self.currentTrack().head()
self.currentTrack().goToTickindex(nowInTicks) #that is already the new current track
return result
def deleteTrack(self, track):
if track == self.currentTrack():
return self.deleteCurrentTrack() #redirect. This way we have no problems with cursor positions etc.
if len(self.tracks) > 1:
#We need to know if the old current track was AFTER the deleted one. In this case the trackindex needs to get subtracted by one (before the track is actually deleted)
result = self.currentTrack(), self.trackIndex #so undo has something to save
if self.trackIndex > self.tracks.index(track): #there is only > or <. == is ruled out above.
self.trackIndex -= 1
super().deleteTrack(track)
#track.prepareForDeletion()
#self.tracks.remove(track)
return result
def hideTrack(self, track):
"""This track cannot be edited while hidden.
We don't delete any calfbox data here, contrary to delete Track.
That means we don't need to touch anything and the playback
version will remain correct.
Also make sure we are in the right track afterwards since
hideTrack changes the trackIndices"""
assert track in self.tracks #track is visible so it should be in the list of visible tracks
assert not track in self.hiddenTracks #track is visible so it should not be in the list of hidden tracks
if len(self.tracks) > 1: #same rules as for delete. do not hide the last visible track
self.hiddenTracks[track] = self.tracks.index(track)
if self.trackIndex == self.tracks.index(track): #the track with the active cursor gets hidden
self.tracks.remove(track)
self.trackIndex = 0
else:
currentTrack = self.tracks[self.trackIndex]
self.tracks.remove(track)
self.trackIndex = self.tracks.index(currentTrack) #find out where the current track is now.
return True #succesful
def unhideTrack(self, track):
assert not track in self.tracks #track is hidden so it should not be in the list of visible tracks
assert track in self.hiddenTracks #track is hidden so it should be in the list of hidden tracks
currentTrack = self.tracks[self.trackIndex]
self.tracks.insert(self.hiddenTracks[track], track)
del self.hiddenTracks[track]
self.trackIndex = self.tracks.index(currentTrack) #find out where the current track is now.
return True
def trackUp(self):
if self.trackIndex > 0:
#there is still a track above. At least index 0 is there.
nowInTicks = self.currentTrack().state.tickindex
self.trackIndex -= 1
self.currentTrack().goToTickindex(nowInTicks)
return True
def trackDown(self):
if self.trackIndex < len(self.tracks)-1:
#there is still a track below
nowInTicks = self.currentTrack().state.tickindex
self.trackIndex += 1
self.currentTrack().goToTickindex(nowInTicks)
return True
def trackFirst(self):
nowInTicks = self.currentTrack().state.tickindex
self.trackIndex = 0
self.currentTrack().goToTickindex(nowInTicks)
def trackLast(self):
nowInTicks = self.currentTrack().state.tickindex
self.trackIndex = len(self.tracks)-1
self.currentTrack().goToTickindex(nowInTicks)
def cursorExport(self):
return self.cursor.exportObject(self.currentTrack().state)
def _cursorExportTrackTick(self, track, tickPosition):
originalPosition = track.state.position()
track.goToTickindex(tickPosition)
exportCursor = self.cursor.exportObject(track.state)
track.toPosition(originalPosition) #has head() in it
return exportCursor
def setSelectionBeginning(self):
if self.cursorWhenSelectionStarted:
return False #because we did nothing
else:
self.cursorWhenSelectionStarted = self.cursorExport() #There was no selection before. setSelectionBeginning is called by selection-creating functions. So this was the first step of shift+right or so.
return True #because we did something
def goToSelectionEnd(self):
"""Keeps the selection alive.
Written for processing functions like apply to selection.
Or maybe as a user command.
Is not used in the internal score selection commands because
we want to keep everything original."""
result = self.checkSelectionOrder()
if not result:
raise RuntimeError("called score.goToSelectionEnd without a selection")
else:
topLeftCursor, bottomRightCursor = result
#The cursor when selection started is always the opposite of the current cursor position. except if the selection has zero width, which is ruled out by the test above.
if self.cursorWhenSelectionStarted == topLeftCursor: #already at the end
return False
else: #self.cursorWhenSelectionStarted == bottomRightCursor:
self.cursorWhenSelectionStarted = topLeftCursor
self.goTo(bottomRightCursor["trackIndex"], bottomRightCursor["blockindex"], bottomRightCursor["localCursorIndex"]) #does not work if the number of items change. e.g. split
return True
#else: This fires when the selection goes from bottomLeft to topRight or reverse. Make the "elif" above the new "else".
#raise ValueError("Cursor was neither at the start nor the end of the selection but a selection was present. This is a serious error. Only selections with the cursor on either end are allowed")
def goToSelectionStart(self):
result = self.checkSelectionOrder()
if not result:
raise RuntimeError("called score.goToSelectionEnd without a selection")
else:
topLeftCursor, bottomRightCursor = result #one of these is always the current cursor, not a cached value. You don't need to test if the current cursor is actually one of these two.
#The cursor when selection started is always the opposite of the current cursor position. except if the selection has zero width, which is ruled out by the test above.
if self.cursorWhenSelectionStarted == bottomRightCursor: #already at the beginning
return False
else:# self.cursorWhenSelectionStarted == topLeftCursor:
self.cursorWhenSelectionStarted = bottomRightCursor
self.goTo(topLeftCursor["trackIndex"], topLeftCursor["blockindex"], topLeftCursor["localCursorIndex"]) #contrary to goToSelectionEnd this works always. No duration or number change can affect this position.
return True
#else: This fires when the selection goes from bottomLeft to topRight or reverse. Make the "elif" above the new "else".
# raise ValueError("Cursor was neither at the start nor the end of the selection but a selection was present. This is a serious error. Only selections with the cursor on either end are allowed")
def checkSelectionOrder(self):
"""Return the correct order of the selection boundaries
(topLeft, bottomRight)
Returns empty tuple if we don't have a selection or
a selection of size 0."""
if self.cursorWhenSelectionStarted: #is None if we don't have a selection at all. Otherwise a cursor export object like self.cursorExport()
current = self.cursorExport()
if self.cursorWhenSelectionStarted["tickindex"] == current["tickindex"]:
#The only time this should happen is after a selection modification by the user which results in having no selections. e.g. "select right", "select left".
#A special case if a selection changes direction, or "inverts".
#Note that we don't reset self.cursorWhenSelectionStarted. The selection process is still going on.
return tuple()
#we have a selection with dimensions > 0.
if self.cursorWhenSelectionStarted["trackIndex"] < current["trackIndex"]: #selection order GUI-view: top to bottom
if self.cursorWhenSelectionStarted["tickindex"] < current["tickindex"]: #selection order left to right
result = self.cursorWhenSelectionStarted, current
else: #selection order right to left. Both topLeft and bottomRight are not created by the user directly. We have to guess what start item the user wants which can be hard if zero-duration items are involved.
#There is no other way to go the tickindex of the real first item and force a cursor export.
topLeft = self._cursorExportTrackTick(self.cursorWhenSelectionStarted["track"], current["tickindex"]) #yes, the tickindex of the other track
bottomRight = self._cursorExportTrackTick(current["track"], self.cursorWhenSelectionStarted["tickindex"]) #yes, the tickindex of the other track
return topLeft, bottomRight
elif self.cursorWhenSelectionStarted["trackIndex"] > current["trackIndex"]: #selection order GUI-view: bottom to top
if self.cursorWhenSelectionStarted["tickindex"] > current["tickindex"]: #selection order left to right
result = current, self.cursorWhenSelectionStarted
else: #selection order right to left. Both topLeft and bottomRight are not created by the user directly. We have to guess what start item the user wants which can be hard if zero-duration items are involved.
#There is no other way to go the tickindex of the real first item and force a cursor export.
bottomRight = self._cursorExportTrackTick(self.cursorWhenSelectionStarted["track"], current["tickindex"]) #yes, the tickindex of the other track
topLeft = self._cursorExportTrackTick(current["track"], self.cursorWhenSelectionStarted["tickindex"]) #yes, the tickindex of the other track
return topLeft, bottomRight
else: #in the same track
assert self.cursorWhenSelectionStarted["trackIndex"] == current["trackIndex"] #anything else is logically impossible.
if self.cursorWhenSelectionStarted["position"] <= current["position"]:
result = self.cursorWhenSelectionStarted, current
else:
result = current, self.cursorWhenSelectionStarted
return result
else:
return tuple()
def selectionExport(self):
"""The boundaries of the selection as two cursor export
objects. So not the selected items.
The selectionExport is sorted.
See api.callbacks _setSelection for more documentation
For the sake of simplicity a selection is only valid
if the start-tick and end-tick in all tracks are the same.
An invalid selection does not prevent the extension or
modification of a selection itself but it prevents
processing its content.
"""
#TODO: If this is a performance problem it has the potential to get cached. Functions like listOfSelectedItems can then use the cached value.
result = self.checkSelectionOrder()
if not result:
return result #empty tuple
topLeft, bottomRight = result
startTick = topLeft["tickindex"]
endTick = bottomRight["tickindex"]
tracksWithSelectedItems = [track for track in self.tracks[topLeft["trackIndex"]:bottomRight["trackIndex"]+1]] #slice does not include the last value, so +1
for track in tracksWithSelectedItems:
originalPosition = track.state.position()
track.goToTickindex(startTick) #includes track.head() which resets the state
if (not track.state.tickindex == startTick):
break
track.goToTickindex(endTick)
if (not track.state.tickindex == endTick):
break
track.toPosition(originalPosition)
else: #not executed if break. Means everything was all right.
return (True, topLeft, bottomRight)
#We only get here through a for-loop break, which means the selection is invalid. But we still export the selection boundaries for the GUI
#Reset the last track
track.toPosition(originalPosition) #has head() in it
return (False, topLeft, bottomRight)
def listOfSelectedItems(self, removeContentLinkedData:bool):
"""
validSelection, topLeftCursor, bottomRightCursor, listOfChangedTrackIds, *selectedTracksAndItems = returnedStuff
Most important is the data, which is in selectedTracksAndItems and has this format:
[
[(object, {"keySignature": keysigobject}), (object, {"keySignature": keysigobject}), (object, {"keySignature": keysigobject}), ...], #track 1
[(object, {"keySignature": keysigobject}), (object, {"keySignature": keysigobject}), ...], #track 2
]
Please note that the 2nd in the tuple, the cached track state, will be taken out by copyObjects.
If you want to create a buffer by hand (like the midi module does) just use a list of track-lists with items in it
Block boundaries are ignored. Just the content.
We keep the relative position to each other and the relative
tracks.
Eleminates duplicate items. That means content-linked block.data
is only in here once.
Use this for processing, not for analysis.
Also don't depend on len() or use a for item in ... loop as
counter.
You can deactivate this removal by setting the parameter
removeContentLinkedData to False. self.copyObjects() does this.
There is no processing in this function. All items stay the same
and subsequently have the same durations, positions and
tickindices.
When it gets to processing you need to be careful because those
values change. The only value which is constant is the topLeft
tickindex and position.
We take care of duration-changed content linked items left of the selection as well.
"""
t = self.selectionExport() #is there a selection at all?
if not t:
return [False, None, None, [], []] #mimickes the true result. the api can use this to decide to do nothing instead of processing the selection
else:
selectionValid, topLeft, bottomRight = t
if not selectionValid:
return [False, topLeft, bottomRight, [], []] #mimics the result format. the api can use this to decide to do nothing instead of processing the selection
startTick = topLeft["tickindex"]
endTick = bottomRight["tickindex"]
seenItems = set() #keep duplicate items, aka. content link block data, out of the result.
tracksWithSelectedItems = [track for track in self.tracks[topLeft["trackIndex"]:bottomRight["trackIndex"]+1]] #slice does not include the last value, so +1
#listOfChangedTrackIds = set([id(track) for track in tracksWithSelectedItems]) #this is not the same as tracksWithSelectedItems. It gets more ids than the initial ones here. A content-linked note outside a selected track will still be affected. We start with the trackIds we already know.
listOfChangedTrackIds = set()
finalResult = [selectionValid, topLeft, bottomRight, listOfChangedTrackIds] #listOfChangedTrackIds is mutable. It will change in the loop below
firstRound = True
for track in tracksWithSelectedItems:
assert track in self.tracks, track #selecting from hidden or deleted tracks is impossible
originalPosition = track.state.position()
result = []
if firstRound: #first track must be accurate. The user selected a specific item, find that, not the tick position which may include zero duration items
track.toItemInBlock(topLeft["item"], topLeft["block"])
firstRound = False
else: #all other tracks are only approximations
track.goToTickindex(startTick, skipOverZeroDurations = False) #includes track.head() which resets the state
while track.state.tickindex <= endTick: #we must use the tickindex here because we do not know the end items of the tracks
r = track.right()
if r == 1:
# the item we want is already left of the cursor. So we want the previous item.
if track.state.tickindex <= endTick: #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.
item = track.previousItem()
if (not removeContentLinkedData) or (not item in seenItems):
result.append((item, {"keySignature" : track.state.keySignature()})) #The keysig is needed for undo operations after apply to selection
seenItems.add(item)
for parentBlock in item.parentBlocks: #TODO: just be naive and add them all for now. Performance can be improved later.
#assert parentBlock, item
#assert parentBlock.parentTrack, (parentBlock, parentBlock.parentTrack)
if parentBlock.parentTrack:
assert parentBlock.parentTrack in self.tracks, (parentBlock.parentTrack, id(parentBlock.parentTrack), hex(id(parentBlock.parentTrack)))
listOfChangedTrackIds.add(id(parentBlock.parentTrack)) #there are items without parent Tracks. They have None. We must filter them out otherwise our changedTrackIds get polluted with id(None)
else:
continue
elif r == 2: #block end. Again, this is already the previous state. right now we are in the next block already.
continue
else: #When does this happen again?
break
track.toPosition(originalPosition) #has head() in it
finalResult.append(result)
for changedTrId in listOfChangedTrackIds: #It is not the track ids that change but tracks changed and these are their IDs:
assert self.trackById(changedTrId) in self.tracks, changedTrId #selecting from hidden or deleted tracks is impossible
return finalResult
#The next two functions have "Objects" in their name so copy gets not confused with block.copy or item.copy. This is Ctrl+C as in copy and paste.
#Cut is done in the api module
def copyObjects(self, writeInSessionBuffer = True):
"""This is Ctrl+c
Only works with valid selections.
We can assume the same tick-duration in all tracks.
We can also assume that tracks and objects are continuous.
There are no gaps in the data.
for external functions: len(result) gives the number of tracks.
You can just return the buffer without overwriting the clipboard if you set writeInBuffer
to False. api.duplicate uses this.
"""
#selectedItems = self.listOfSelectedItems(removeContentLinkedData = False) #list of tracks with selected objects. no block data, just the objects.
validSelection, topLeftCursor, bottomRightCursor, listOfChangedTrackIds, *selectedTracksAndItems = self.listOfSelectedItems(removeContentLinkedData = False)
result = []
if validSelection:
for track in selectedTracksAndItems:
result.append([item.copy() for item, cachedTrackState in track])
if writeInSessionBuffer:
self.copyObjectsBuffer = result
return result
else:
#self.copyObjectsBuffer = [] #maybe that is a bit harsh. If you press Cltr+C by accident, without a selection, you loose your copied data. This is especially bad for cut and paste
return []
def getIndenpendetCopyObjectsBuffer(self):
"""Requests a copy of the copy-buffer, if you want to modify it in-memory before pasting
it. Used by pasting a transposed version"""
result = []
for track in self.copyObjectsBuffer:
result.append([item.copy() for item in track])
return result
def pasteObjects(self, customBuffer = None, overwriteSelection = True):
"""This is ctrl+v
Rules:
If you have copied n tracks you need at least n-1 tracks below
the current. In other words: The number of tracks you copied
is the number of tracks you paste.
There needs to be a cursor position, aka slot, at exactly the
current tickindex in all tracks you are going to paste to.
Since the copied area is always a 'rectangle' with the same
tick-duration in each track we want this to be a perfect
paste as well: everything starts and ends at the same position.
"""
if (not self.copyObjectsBuffer) and (not customBuffer):
return False
if customBuffer:
workBuffer = customBuffer
else:
workBuffer = self.copyObjectsBuffer
#First do a few tests if we are allowed to paste
########################################################
tickStart = self.currentTrack().state.tickindex
trackIndexStart = self.trackIndex
startPosition = self.where() #trackIndex, blockindex, localCursorIndexInBlock
startItem = self.currentItem()
#Check if there are enough tracks below
if len(workBuffer)+trackIndexStart > len(self.tracks):
#not enough tracks
broken = True
#Don't leave this function here because we need to return to the start position below.
else:
broken = False
if not broken:
#Check if all tracks have a slot at our tickindex
for track in workBuffer:
curTrack = self.currentTrack()
curTrack.goToTickindex(tickStart)
if not curTrack.state.tickindex == tickStart: #no slot
#not the same tick positions
broken = True
break
self.trackDown()
else:
broken = False
#return
#while not self.trackIndex == trackIndexStart:
# self.trackUp()
#self.currentTrack().goToTickindex(tickStart)
self.goTo(*startPosition) #has assertions as test within
if broken:
return False
#Up to this point no modifications were made. The cursor is in the same position as it was before.
#########################################################
#We are ready to paste. There are enough tracks and an empty slots at exactly the tickindex we need.
if self.cursorWhenSelectionStarted and overwriteSelection:
listOfChangedTrackIdsFromDelete = self.deleteSelection()
startPosition = self.where()
startItem = self.currentItem()
#TODO: check if the cursor is still in the correct position after delete selection
listOfChangedTrackIds = set()
for number, track in enumerate(workBuffer):
curTrack = self.currentTrack()
#Here comes an additional condition. Most of the time pastes are single-track. If so, or we simply are in the starting track, we want to paste at the actual position, not the tickindex. This circumvents problems with block boundaries and zero-duration items.
#TODO: In the future add a "smart paste" that checks if the cursor starts at the beginning or at the end of zero duration items or a block boundary and try to match that position in the other tracks, eventhough we can't be certain if these conditions actually match
if self.trackIndex == trackIndexStart:
self.goTo(*startPosition) #has assertions as test within
else:
curTrack.goToTickindex(tickStart)
#assert curTrack.state.tickindex == tickStart #does not work with overwrite selection
for item in track:
newItem = item.copy()
curTrack.insert(newItem) #eventhough copyObjectsBuffer is already a copy of the original notes we don't want every paste to be the same instances.
#newItem has now a parentBlock and a parentTrack. We add this parent track to the list of changed Tracks
listOfChangedTrackIds.update(newItem.parentTrackIds) #TODO: profiling. All items in a block have the same parentTrack. This is highly redundant BUT can be different for items which originated from block boundaries
if not number+1 == len(workBuffer): #enumerate from 0, len from 1.
#we have to prevent the track going down one too far in the last step and messing with the tick index though.
self.trackDown()
#once again, return to the start track and go to the final tick index. Make the cursor ready to paste again in succession.
finalTickIndex = self.currentTrack().state.tickindex
while not self.trackIndex == trackIndexStart:
self.trackUp()
assert self.trackIndex == trackIndexStart == startPosition[0]
assert self.currentTrack().state.tickindex == finalTickIndex #this is not enough to pinpoint the real location.
#Return to the item where pasting starting. Pasting counts as insert, and insert sticks to the item right of cursor.
#Therefore we go to the starting track and block, but not to the starting localCursorIndexInBlock. Instead we search for the specific item in a second step
self.goTo(trackIndex=startPosition[0], blockindex=startPosition[1], localCursorIndexInBlock=0)
self.goToItemInCurrentBlock(startItem) #we actually want to be at the same item where we started, not just the tick index which gets confused with block boundaries and zero duration items
try: #overwrite? #TODO: What is that? assert with AssertionError pass?
assert listOfChangedTrackIds == listOfChangedTrackIdsFromDelete
except AssertionError:
pass
except UnboundLocalError:
pass
return listOfChangedTrackIds
def deleteSelection(self):
"""Mortals, Hear The Prophecy: Delete Selection is notoriously tricky. It was hard in Laborejo 1 and it is still hard
here. Please use as many asserts and tests as you possibly can. The next bug or regression
WILL come. And it will be here.
Will only delete from visible tracks. No hidden, no deleted.
"""
validSelection, topLeftCursor, bottomRightCursor, listOfChangedTrackIds, *selectedTracksAndItems = self.listOfSelectedItems(removeContentLinkedData = True)
#selectedTracksAndItems format: [track1[(item, itsKeysig), (item, itsKeysig), (item, itsKeysig)], track2[(item, itsKeysig), (item, itsKeysig), (item, itsKeysig)]]
if validSelection:
numberOfTracks = len(selectedTracksAndItems)
firstSelectedItem = selectedTracksAndItems[0][0][0]
lastSelectedItem = selectedTracksAndItems[-1][-1][0]
#We need to find the topLeft selected item. This is NOT goToSelectionStart() which would be the actual starting point of the user, which includes bottomRight.
#go to first selected track, starting tick position. selections are always top left to bottom right
while not self.trackIndex == topLeftCursor["trackIndex"]:
self.trackUp()
#self.currentTrack().goToTickindex(topLeftCursor["tickindex"], skipOverZeroDurations = False)
self.currentTrack().toItemInBlock(topLeftCursor["item"], topLeftCursor["block"]) #includes track.head() which resets the state
#print ("start deleting from", topLeftCursor["item"], "to BEFORE this item", bottomRightCursor["item"])
assert topLeftCursor["track"] is self.currentTrack(), (topLeftCursor["track"], self.currentTrack())
assert topLeftCursor["block"] is self.currentTrack().currentBlock(), (topLeftCursor["block"], self.currentTrack().currentBlock())
assert topLeftCursor["item"] is self.currentTrack().currentItem(), (topLeftCursor["item"], self.currentTrack().currentItem())
assert topLeftCursor["itemId"] == id(self.currentTrack().currentItem()), (topLeftCursor["itemId"], id(self.currentTrack().currentItem()))
if topLeftCursor["item"]:
assert topLeftCursor["item"] is firstSelectedItem, (topLeftCursor["item"], firstSelectedItem)
#else: yet unknown set of scenarios. One known is that the selection was from right to left, included normal duration items but ends on a block split which is at the tracks beginning. most likely a leftover from some deletion.
#NO! bottomRight is not the last selected item but the first item that is not selected anymore!! assert bottomRightCursor["item"] is lastSelectedItem, (bottomRightCursor["item"], lastSelectedItem)
assert self.currentTrack().currentBlock().localCursorIndex == topLeftCursor["localCursorIndex"], (self.currentTrack().currentBlock().localCursorIndex, topLeftCursor["localCursorIndex"])
firstRound = True
for number, selectionTrack in enumerate(selectedTracksAndItems):
curTrack = self.currentTrack()
if firstRound: #In the first track the user selected a specific item, which may be zero duration or not. We must delete this, not more not less. All other tracks are approximations and can work with the tickindex.
self.currentTrack().toItemInBlock(topLeftCursor["item"], topLeftCursor["block"]) #includes track.head() which resets the state
firstRound = False
else:
curTrack.goToTickindex(topLeftCursor["tickindex"], skipOverZeroDurations = False)
assert curTrack.state.tickindex == topLeftCursor["tickindex"], (curTrack.state.tickindex, topLeftCursor["tickindex"])
#We delete always from the same cursor position. After one deletion the rest items gravitate left to our position. Except block boundaries, those will not be crossed so we have to check for them:
for item, cachedState in selectionTrack:
if item is bottomRightCursor["item"]:
break #without this the program will delete the first non-selected item, if zero duration, as well
endOfTrackBreaker = False
while curTrack.currentBlock().isAppending(): #Block end, or even track end
if not curTrack.right(): #end of track.
endOfTrackBreaker = True
break
if endOfTrackBreaker:
break
assert curTrack.currentItem() is item, (curTrack.currentItem(), item) #since listOfSelectedItems already removed all duplicate items this will work.
curTrack.delete() # side effect: delete the actual item. returns none on block boundary.
#Don't go down if we are already in the last track of the selection
if not number+1 == numberOfTracks: #enumerate from 0, len from 1.
self.trackDown()
#assert curTrack.state.tickindex == topLeftCursor["tickindex"] We cannot be sure of this. The selection could have started at a content-linked item that got deleted and the tickindex has no item anymore
#return [id(track) for track in self.tracks] #TODO: only for debug reasons. the GUI will draw everything with this! Either this function or the api has to figure out the tracks that really changed. we could do a len check of the blocks data.
return listOfChangedTrackIds
def getBlockAndItemOrder(self):
"""
Docstring for getBlockAndItemOrder and putBlockAndItemOrder.
getBlockAndItemOrder creates a complete snapshot of
the whole score. Since saving tracks and blocks in the
usual musical order of magnitude is no performance-problem
for python.
It can be put back in the score with putBlockAndItemOrder.
We use this for undo/redo of deleteSelection and paste.
Even after items got deleted deleteSelection,
they are still avaible to get re-inserted as long as the data
was kept around somewhere, for example in the undo/redo stack.
Effectively we discard any additional items introduced by paste
or restore lost items through deleteSelection.
This is only the order of items, not the state of the items,
but since undo/redo works lineary there can be no changes
to the items left. They would have been undone before this
step was possibly at all.
It is very important to be aware of content-linked blocks data.
If there is such a block in the given data all tracks with its
link must be in dictWithTrackIDsAndDataLists as well, with
the data already linked again.
This is easy when we save a complete snapshot instead of
selectively choosing tracks. However, the api callbacks
must only update tracks that really changed, not the whole
score. This would be far too expensive for a GUI.
Other functions do not need to filter the dict when using
putBlockAndItemOrder. It looks for true changes itself
and returns a list of trackIDs that need callback updates.
No blocks will be deleted or replaced with this because
deleteSelection leaves empty blocks and paste is not capable
of creating new blocks.
"""
result = {} #track id, blockAndItemOrder
seen = {} # originalDataId : sliceCopy. Takes care of content linked blocks data.
for track in self.tracks:
newWorldOrder = []
for block in track.blocks:
blockDataId = id(block.data)
if not blockDataId in seen:
seen[blockDataId] = block.data[:]
newWorldOrder.append(seen[blockDataId])
result[id(track)] = newWorldOrder
return result
def putBlockAndItemOrder(self, dictWithTrackIDsAndDataLists):
"""see getBlockAndItemOrder for documentation"""
for trackId, listWithBlocksData in dictWithTrackIDsAndDataLists.items():
assert len(self.trackById(trackId).blocks) == len(listWithBlocksData) #paste and delete selection never delete or create blocks. So the len is always the same. we can zip
for block, parameterBlockData in zip(self.trackById(trackId).blocks, listWithBlocksData):
block.data = parameterBlockData
return [id(track) for track in self.tracks] #TODO: only for debug reasons. the GUI will draw everything with this! Either this function or the api has to figure out the tracks that really changed. we could do a len check of the blocks data.
def listOfTrackIds(self):
"""Mostly used for the initial callback sweep after loading
a new file in the api."""
#return Track.allTracks.keys() #this does contain deleted tracks (in the undo stack) as well. It is only valid when the program starts up. The undo stack is empty then.
#assert len (Track.allTracks) == len(self.tracks) #make sure to only call this function when it is correct. If we ever use this for non-startup reasons change to the list comprehension below.
#return Track.allTracks.keys()
return [id(track) for track in self.tracks] #tracks in the score, not in the undo stack.
def listOfStaticTrackRepresentations(self):
"""A list of tracks"""
result = []
for track in self.tracks:
static = track.staticTrackRepresentation()
static["hiddenPosition"] = None
result.append(static)
return result
def listOfStaticHiddenTrackRepresentations(self):
"""A list of hidden tracks. Mutually exclusive with
listOfStaticTrackRepresentations """
result = []
for track, originalPosition in self.hiddenTracks.items():
static = track.staticTrackRepresentation()
static["hiddenPosition"] = originalPosition
result.append(static)
return result
def graphBlockById(self, blockId):
for track in self.tracks:
for cc, graphTrackCC in track.ccGraphTracks.items():
for graphBlock in graphTrackCC.blocks:
if id(graphBlock) == blockId:
return id(track), cc, graphBlock
else:
raise ValueError("graphBlock with this id not in any track")
def graphItemById(self, graphItemId):
for track in self.tracks:
for cc, graphTrackCC in track.ccGraphTracks.items():
try:
graphBlock, graphItem = graphTrackCC.graphItemById(graphItemId)
return id(track), cc, graphBlock, graphItem
except ValueError: #raised when not in thie cc/track
pass
else:
raise ValueError("graphItem with this id not in any track")
def goTo(self, trackIndex, blockindex, localCursorIndexInBlock):
"""Handles even shifting positions in a content link situation"""
self.trackIndex = trackIndex
self.currentTrack().toBlockAndLocalCursorIndex(blockindex, localCursorIndexInBlock)
assert self.where() == (trackIndex, blockindex, localCursorIndexInBlock)
def where(self):
"""return the data needed by self.goto
where even keeps the local position if a content linked block inserts items before our position """
return self.trackIndex, self.currentTrack().state.blockindex, self.currentTrack().currentBlock().localCursorIndex
def goToItemInCurrentBlock(self, itemInstance):
"""Can also be None for the next block-end/appending """
self.currentBlock().goToItem(itemInstance)
#Deprecated
#def goToItem(self, itemInstance):
# """aka search for item. Item instances are unique across a session"""
# this is quite expensive. Most of the time we already know the track and block. Use score.goTo with localCUrsorIndexInBlock=0
def allItems(self, hidden = True, removeContentLinkedData = False):
seenItems = set()
if hidden:
what = list(self.hiddenTracks.keys()) + self.tracks
#what = Track.allTracks.values() #this includes deleted tracks
else:
what = self.tracks
for track in what:
for block in track.blocks:
for item in block.data:
if (not removeContentLinkedData) or (not item in seenItems):
seenItems.add(item)
yield item
def transposeScore(self, rootPitch, targetPitch):
"""Based on intervalAutomatic"""
for item in self.allItems(removeContentLinkedData=True):
item.intervalAutomatic(rootPitch, targetPitch)
def joinCurrentBlockAndAllContentLinks(self):
"""This is in score and not in track because it works on content links across tracks as well
Take pairs of blocks current+next and all its content links in the score and join them
together. The blocks will get the meta-information of the first block.
a) Every content linked block to the current one is followed by
a block which is content-linked itself to all other follow-ups.
b) The current block has no content links. This is really just
special case of a)
All content links that are followed by their own link are caught as well.
Logic dictates that at least one of the content links will have either no follow up
(end of track) or a different follow up. Both are cases that will prevent a join from
happening.
This function does not actually join the blocks but creates new blocks that will replace
the old pairs. We then return a data structure that api.rearrangeBlocksInMultipleTracks
can use to create the new order. The old blocks are kept for undo and redo.
dictOfTrackIdsWithListOfBlockIds is [trackId] = [listOfBlockIds]
"""
startItem = self.currentTrack().currentItem()
startBlock = self.currentTrack().currentBlock()
workingList = [] #pairs of (block, followUpBlock)
try:
startFollowUpBlock = startBlock.parentTrack.blocks[startBlock.parentTrack.blocks.index(startBlock) +1] #the follow up for the current block.
except:
#current block already without follow up
return False #already no follow up. Leave.
#Get the blocks which are actually in the score and not just a reference and compare their lengths.
if not len([True for strtblk in startBlock.linkedContentBlocks if strtblk._parentTrack]) == len([True for flwupblk in startFollowUpBlock.linkedContentBlocks if flwupblk._parentTrack]):
#startBlock and FollowUp content link registers do not have same length
return False
for block in startBlock.linkedContentBlocks: #all linked blocks in all tracks, including currentBlock. All blocks are unique. The content is not.
blockIndex = block.parentTrack.blocks.index(block)
try:
followUpBlock = block.parentTrack.blocks[blockIndex +1]
except IndexError: #there is no follow up block. This violates our two requirements.
#block without follow up found
return False
else: #the block is there. continue:
if not followUpBlock in startFollowUpBlock.linkedContentBlocks:
#not all follow up blocks are the same content
return False
else:
workingList.append((block, followUpBlock))
#we now have made it clear that the requirements from the docstring are met.
dictOfTrackIdsWithListOfBlockIds = {}
protoBlock = startBlock.copy(newParentTrack = self.currentTrack())
protoBlock.data += startFollowUpBlock.data
for firstBlock, secondBlock in workingList:
parentTrackId = id(firstBlock.parentTrack)
assert firstBlock.parentTrack in self.tracks, (firstBlock.parentTrack, id(firstBlock.parentTrack), hex(id(firstBlock.parentTrack)))
if parentTrackId in dictOfTrackIdsWithListOfBlockIds:
blockOrder = dictOfTrackIdsWithListOfBlockIds[parentTrackId]
else:
blockOrder = firstBlock.parentTrack.asListOfBlockIds()
uniqueBlockForThisPosition = firstBlock.copy(newParentTrack = firstBlock.parentTrack)
uniqueBlockForThisPosition.name = uniqueBlockForThisPosition.name[:-5] #remove "-copy"
uniqueBlockForThisPosition.data = protoBlock.data
uniqueBlockForThisPosition.linkedContentBlocks = protoBlock.linkedContentBlocks
uniqueBlockForThisPosition.linkedContentBlocks.add(uniqueBlockForThisPosition)
assert id(uniqueBlockForThisPosition) in Block.allBlocks
blockOrder[blockOrder.index(id(firstBlock))] = id(uniqueBlockForThisPosition)
assert not id(firstBlock) in blockOrder
#We don't need to actually delete the blocks here, this will be done indirectly by rearrangeBlocks through the api later.
blockOrder.remove(id(secondBlock))
assert not id(secondBlock) in blockOrder
dictOfTrackIdsWithListOfBlockIds[parentTrackId] = blockOrder #eventhough it is mutable and therefore changes in the list are reflected in the dict-value we did not make sure that the list is in the dict in the first place above.
protoBlock.linkedContentBlocks.remove(protoBlock)
del(protoBlock)
return dictOfTrackIdsWithListOfBlockIds
def splitCurrentBlockAndAllContentLinks(self):
"""split the current block around the cursor position.
If used in an appending position it just creates a new block.
The new block gets inserted directly right of
each original content-linked block.
For the user this looks like all contentLinked blocks got
splitted in half, but they remain content linked.
"""
track = self.currentTrack()
startBlock = track.currentBlock() #this is the only unique thing we can rely on.
#startBlock will get deleted, but we don't remove the blocks as parent because we need them for undo
#They will be finally cleared on save.
#NO. This works for splitting, but not for undo. for item in startBlock.data:
# item.parentBlocks.remove(startBlock)
dictOfTrackIdsWithListOfBlockIds = {} # [trackId] = [listOfBlockIds]
protoBlockLeft = startBlock.copy(track)
protoBlockLeft.data = startBlock.data[:startBlock.localCursorIndex] #everything left of the cursor. The local cursor index of the current block is guaranteed to be correct. The one from other blocks is not.
protoBlockRight = startBlock.copy(track)
protoBlockRight.data = startBlock.data[startBlock.localCursorIndex:] #everything right of the cursor. The local cursor index of the current block is guaranteed to be correct. The one from other blocks is not.
for block in startBlock.linkedContentBlocksInScore(): #all linked blocks in all tracks, including currentBlock. All blocks are unique. The content is not.
parentTrackId = id(block.parentTrack)
if parentTrackId in dictOfTrackIdsWithListOfBlockIds:
blockOrder = dictOfTrackIdsWithListOfBlockIds[parentTrackId]
else:
blockOrder = block.parentTrack.asListOfBlockIds()
newLeft = protoBlockLeft.contentLink()
newLeftId = id(newLeft)
newLeft.name = block.name
newLeft.minimumInTicks = block.minimumInTicks / 2
assert newLeftId in Block.allBlocks
assert newLeft.parentTrack is block.parentTrack
assert newLeft in protoBlockLeft.linkedContentBlocks
newRight = protoBlockRight.contentLink()
newRightId = id(newRight)
newRight.name = block.name
newRight.minimumInTicks = newLeft.minimumInTicks
assert newRightId in Block.allBlocks
assert newRight.parentTrack is block.parentTrack
assert newRight in protoBlockRight.linkedContentBlocks
positionToInsert = blockOrder.index(id(block))
blockOrder[positionToInsert] = newLeftId #replaces the original block which will be kept intact for undo
blockOrder.insert(positionToInsert + 1, newRightId)
dictOfTrackIdsWithListOfBlockIds[parentTrackId] = blockOrder #eventhough it is mutable and therefore changes in the list are reflected in the dict-value we did not make sure that the list is in the dict in the first place above.
protoBlockLeft.linkedContentBlocks.remove(protoBlockLeft) #probably redundant. But makes the intention clear.
protoBlockRight.linkedContentBlocks.remove(protoBlockRight)
del(protoBlockLeft)
del(protoBlockRight)
return dictOfTrackIdsWithListOfBlockIds
def removeEmptyBlocks(self):
dictOfTrackIdsWithListOfBlockIds = {} # [trackId] = [listOfBlockIds]
#for trId, track in Track.allTracks.items():
for track in list(self.hiddenTracks.keys()) + self.tracks:
if len(track.blocks) <= 1:
continue #next track
#Get all blocks and then remove those with no content.
trId = id(track)
listOfBlockIds = track.asListOfBlockIds()
for block in track.blocks:
if not block.data:
listOfBlockIds.remove(id(block))
#Maybe the track was empty. In this case we add one of the blocks again. Otherwise follow up functions will not act.
if not listOfBlockIds:
listOfBlockIds.append(id(block))
dictOfTrackIdsWithListOfBlockIds[trId] = listOfBlockIds
return dictOfTrackIdsWithListOfBlockIds
def lilypond(self):
"""Entry point for converting the score into lilypond.
Called by session.saveAsLilypond(), returns a string, which is the file content.
What is happening here?
Only visible tracks get exported.
Each object controls what it exports. Overrides are possible at every level.
"""
tempoStaff = self.tempoTrack.lilypond() if "metronome" in self.metaData and self.metaData["metronome"] else ""
data = {track:track.lilypond() for track in self.tracks} #processed in the lilypond module
#the template file name is in metadata, but it can be empty or not existient
#From Template has direct access to the score metadata and WILL destructively modify it's own parameters, like the lilypond template entry.
return fromTemplate(session = self.parentSession, data = data, meta = self.metaData, tempoStaff = tempoStaff)
def getMidiInputNameAndUuid(self):
"""
Return name and cboxMidiPortUid.
name is Client:Port JACK format
Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and
sequencers. Used by the quick connect midi input widget.
If double None as return the widget in the GUI might hide and deactivate itself."""
return None, None
#Save / Load / Export
def serialize(self)->dict:
dictionary = super().serialize()
dictionary["class"] = self.__class__.__name__
#already in super().serialize: dictionary["tracks"] = [track.serialize() for track in self.tracks]
#we can't save hiddenTracks as dict because the serialized track is a dict itself, which is not hashable and therefore not a dict-key.
dictionary["hiddenTracks"] = [[track.serialize(), originalIndex] for track, originalIndex in self.hiddenTracks.items()]
dictionary["tempoTrack"] = self.tempoTrack.serialize()
dictionary["metaData"] = self.metaData
dictionary["currentMetronomeTrackIndex"] = self.tracks.index(self.currentMetronomeTrack)
return dictionary
@classmethod
def instanceFromSerializedData(cls, parentSession, serializedData):
assert cls.__name__ == serializedData["class"]
self = cls.__new__(cls)
super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap
self.parentSession = parentSession
self.hiddenTracks = {Track.instanceFromSerializedData(parentData=self, serializedData=track):originalIndex for track, originalIndex in serializedData["hiddenTracks"]}
self.tempoTrack = TempoTrack.instanceFromSerializedData(serializedData["tempoTrack"], parentData = self)
self.metaData = serializedData["metaData"]
if not "metronome" in self.metaData: #2.1.0
self.metaData["metronome"] = True #replicate __init__ default
if not "transposition" in self.metaData: #2.2.0
self.metaData["transposition"] = "c c" #replicate __init__ default
if not "template-file" in self.metaData: #2.2.0
self.metaData["template-file"] = "" #replicate __init__ default
self.currentMetronomeTrack = self.tracks[serializedData["currentMetronomeTrackIndex"]]
self._processAfterInit()
return self
def export(self)->dict:
dictionary = super().export()
return {
"numberOfTracks" : len(self.tracks),
"howManyUnits" : self.howManyUnits,
"whatTypeOfUnit" : self.whatTypeOfUnit,
"numberOfMeasures" : self.numberOfMeasures,
"measuresPerGroup" : self.measuresPerGroup,
"subdivisions" : self.subdivisions,
"isTransportMaster" : self.tempoMap.export()["isTransportMaster"],
}