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.
1145 lines
62 KiB
1145 lines
62 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.metaData["global-staff-size"] = 18 # ly scaling . v2.2.1
|
|
|
|
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)
|
|
self.calculateAudibleSoloForCbox()
|
|
|
|
|
|
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.
|
|
if not block.parentTrack:
|
|
continue #hotfix 2022-08-05. It was "None" . #TODO maybe from deleting a link previously that was in multiple tracks?
|
|
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 #that is just not true. We have linked blocks in other tracks as well.
|
|
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: #Don't delete the last block in a track
|
|
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 and not block.minimumInTicks: #empty "spacer" blocks with minimum duration will be kept.
|
|
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 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
|
|
|
|
|
|
def calculateAudibleSoloForCbox(self):
|
|
"""Call this after setting any track audible or solo.
|
|
The function calculates what will actually get played back.
|
|
This is only for Notes. CCs will be always get played back.
|
|
"""
|
|
|
|
anySolo = any(track.solo for track in self.tracks + list(self.hiddenTracks.keys())) #hidden tracks are audible.
|
|
|
|
for track in self.tracks + list(self.hiddenTracks.keys()):
|
|
if anySolo:
|
|
state = track.solo and track.audible
|
|
else:
|
|
state = track.audible
|
|
track.sequencerInterface.enable(state)
|
|
|
|
#Save / Load / Export
|
|
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 if not track.zeroLogicalDuration() } #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 itself.
|
|
return fromTemplate(session = self.parentSession, data = data, meta = self.metaData, tempoStaff = tempoStaff)
|
|
|
|
|
|
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
|
|
if not "global-staff-size" in self.metaData: #2.2.1
|
|
self.metaData["global-staff-size"] = 18 #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"],
|
|
}
|
|
|