diff --git a/engine/api.py b/engine/api.py index ba13f05..102c489 100644 --- a/engine/api.py +++ b/engine/api.py @@ -28,6 +28,7 @@ random.seed() from typing import Iterable, Callable, Tuple #Third Party Modules +from calfbox import cbox #Template Modules import template.engine.api #we need direct access to the module to inject data in the provided structures. but we also need the functions directly. next line: @@ -309,7 +310,6 @@ def startEngine(nsmClient): #General and abstract Commands - def getMetadata(): return session.data.metaData @@ -1324,6 +1324,8 @@ def _applyToSelection(itemFunctionAsString, parameterListForEachSelectedItem = [ Or, a typical case, just one parameter, the same, for each item in the selection. The parameter itself can be a list itself as well. + + The Following example is outdated. Tuplets are no longer a list but just a single tuple, no nesting. We leave this here as example only! For example a duration.tuplets, which is list of tuples(!) theParameter = [[(2,3)]] #This is a tuplet with only one fraction, triplet. It is for a notelist with only one note. #theParameter = [ [(2,3), (4,5)], [(4,5), (1,2), (3,4)] ] #notelist of 2 with a double-nested tuplet for the first and a triple nested tuplet for the second note. @@ -2506,6 +2508,20 @@ def getLilypondRepeatList()->Iterable[Tuple[str, Callable]]: ("Close", lambda: lilypondText('\\bar ":|."')), ] + + +#This and That + +def connectModMidiMerger(): + """Connect our global step midi in to mod-midi-merger, if present""" + jackClientName = cbox.JackIO.status().client_name + try: + cbox.JackIO.port_connect("mod-midi-merger:out", jackClientName + ":in") + except: + pass + + + #Debug def printPitches(): track = session.data.currentTrack() diff --git a/engine/block.py b/engine/block.py index 92ad25e..225c22b 100644 --- a/engine/block.py +++ b/engine/block.py @@ -331,7 +331,11 @@ class Block(object): else: return False - def lilypond(self): - """Called by track.lilypond(), returns a string""" - return " ".join(item.lilypond() for item in self.data) + def lilypond(self, carryLilypondRanges): + """Called by track.lilypond(), returns a string. + carryLilypondRanges is handed from item to item for ranges + such as tuplets. + Can act like a stack or simply remember stuff. + """ + return " ".join(item.lilypond(carryLilypondRanges) for item in self.data) diff --git a/engine/items.py b/engine/items.py index 646b5eb..62cb85e 100644 --- a/engine/items.py +++ b/engine/items.py @@ -184,7 +184,7 @@ class Note(object): "accidental" : self.accidental(trackState.keySignature()), "dotOnLine" : self.asDotOnLine(trackState.clef()), "dots" : self.duration.dots, - "tuplets" : self.duration.tuplets, + "tuplet" : self.duration.tuplet, "notehead" : self.duration.notehead, "completeDuration" : dur, @@ -220,11 +220,11 @@ class Note(object): return result - def lilypond(self): - """Called by chord.lilypond(), See Item.lilypond for the general docstring. + def lilypond(self, carryLilypondRanges): + """Called by chord.lilypond(carryLilypondRanges), See Item.lilypond for the general docstring. Returns two strings, pitch and duration. The duration is per-chord or needs special polyphony.""" - return pitchmath.pitch2ly[self.pitch], self.duration.lilypond() + return pitchmath.pitch2ly[self.pitch], self.duration.lilypond(carryLilypondRanges) class Dynamic(object): """dynamic means velocity midi terms. @@ -319,7 +319,8 @@ class Duration(object): def __init__(self, baseDuration): self._baseDuration = baseDuration #base value. Without dots, tuplets/times , overrides etc. this is the duration for all representations - self.tuplets = [] # a list of tuplets [(numerator, denominator), (,)...]. normal triplet is (2,3). Numerator is the level of the notehead. 2^n Each is one level of tuplets, arbitrary nesting depth. [2,3] for triplet # Tuplet multiplication is used for all representations and gets auto-merged on lilypond output to tuplet groups (if its in a chord). This is a list and not tuple() because json load will make it a list anyway. + #DEPRECTATED. WILL BREAK SAVE FILES. self.tuplets = [] # a list of tuplets [(numerator, denominator), (,)...]. normal triplet is (2,3). Numerator is the level of the notehead. 2^n Each is one level of tuplets, arbitrary nesting depth. [2,3] for triplet # Tuplet multiplication is used for all representations and gets auto-merged on lilypond output to tuplet groups (if its in a chord). This is a list and not tuple() because json load will make it a list anyway. + self.tuplet = None #a single tuple (numerator, denominator). There is no nesting in Laborejo. Numerator is the level of the notehead. 2^n Each is one level of tuplets, arbitrary nesting depth. [2,3] for triplet # Tuplet multiplication is used for all representations and gets auto-merged on lilypond output to tuplet groups (if its in a chord). self.dots = 0 #number of dots. self.durationKeyword = D_DEFAULT #The offsets shift the start and ending to the left and right or rather: earlier and later. positive values mean later, negative values mean earlier. @@ -383,12 +384,12 @@ class Duration(object): new.dots = 2 elif completeDuration *3/2 in durList: #triplets are so a common we take care of them instead of brute forcing with fractions in the else branch new = cls(guessedBase) - new.tuplets = [(2,3)] + new.tuplet = (2,3) else: #tuplet. That means the value is below a standard "notehead" duration. We need to find how much much lower. new = cls(guessedBase) #ratio = completeDuration / guessedBase #0.666~ for triplet newRatio = Fraction(int(completeDuration), guessedBase).limit_denominator(100000) #protects 6 or 7 decimal positions - new.tuplets = [(newRatio.numerator, newRatio.denominator)] + new.tuplet = (newRatio.numerator, newRatio.denominator) else: #the base could not be guessed. In this case we just create a non-standard note. warn("non-standard duration generated") @@ -402,7 +403,16 @@ class Duration(object): assert cls.__name__ == serializedObject["class"] self = cls.__new__(cls) self._baseDuration = int(serializedObject["baseDuration"]) - self.tuplets = serializedObject["tuplets"] #TODO: make sure the types are correct? + + if "tuplets" in serializedObject and serializedObject["tuplets"]: + #old file format, which allowed nested tuplets. Was never used by anyone. + #convert to new single-tuplet format and this will be gone next save. + self.tuplet = serializedObject["tuplets"][0] #just take the first element. There was never a nested tuple, the gui never supported it. + elif "tuplet" in serializedObject and serializedObject["tuplet"]: + #Current file format. + self.tuplet = tuple(serializedObject["tuplet"]) + else: + self.tuplet = None self.dots = int(serializedObject["dots"]) self.durationKeyword = int(serializedObject["durationKeyword"]) self.shiftStart = int(serializedObject["shiftStart"]) @@ -414,7 +424,7 @@ class Duration(object): result = {} result["class"] = "Duration" result["baseDuration"] = self._baseDuration - result["tuplets"] = self.tuplets + result["tuplet"] = self.tuplet result["dots"] = self.dots result["shiftStart"] = self.shiftStart result["shiftEnd"] = self.shiftEnd @@ -436,7 +446,7 @@ class Duration(object): def copy(self): new = Duration(self.baseDuration) - new.tuplets = self.tuplets.copy() + new.tuplet = self.tuplet new.dots = self.dots new.genericNumber = new.calcGenericNumber() new.notehead = new.calcNotehead() @@ -481,7 +491,8 @@ class Duration(object): return self.cachedCompleteDuration else: value = 2 * self.baseDuration - self.baseDuration / 2**self.dots - for numerator, denominator in self.tuplets: + if self.tuplet: + numerator, denominator = self.tuplet value = value * numerator / denominator if not value == int(value): @@ -563,13 +574,13 @@ class Duration(object): self.dots = 0 def toggleTriplet(self): - if self.tuplets == [(2,3)]: - self.tuplets = [] + if self.tuplet == (2,3): + self.tuplet = None else: - self.tuplets = [(2,3)] + self.tuplet = (2,3) - def lilypond(self): - """Called by note.lilypond(), See Item.lilypond for the general docstring. + def lilypond(self, carryLilypondRanges): + """Called by note.lilypond(carryLilypondRanges), See Item.lilypond for the general docstring. returns a number as string.""" if self.durationKeyword == D_TIE: @@ -620,7 +631,7 @@ class DurationGroup(object): def hasTuplet(self): """Return true if there is any tuplet in any note""" - return any(note.duration.tuplets for note in self.chord.notelist) + return any(note.duration.tuplet for note in self.chord.notelist) @property def baseDuration(self): @@ -841,14 +852,14 @@ class Item(object): def mirrorAroundCursor(self, cursorPitch): pass - def lilypond(self): + def lilypond(self, carryLilypondRanges): if self.lilypondParameters["override"]: return self.lilypondParameters["override"] else: - return self._lilypond() + return self._lilypond(carryLilypondRanges) - def _lilypond(self): - """called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """called by block.lilypond(carryLilypondRanges), returns a string. Don't create white-spaces yourself, this is done by the structures. When in doubt prefer functionality and robustness over 'beautiful' lilypond syntax.""" return "" @@ -919,8 +930,8 @@ class TemplateItem(Item): "UIstring" : "", #this is for a UI, possibly a text UI, maybe for simple items of a GUI. Make it as short and unambigious as possible. } - def _lilypond(self): - """called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """called by block.lilypond(carryLilypondRanges), returns a string. Don't create white-spaces yourself, this is done by the structures. When in doubt prefer functionality and robustness over 'beautiful' lilypond syntax.""" return "" @@ -1169,10 +1180,10 @@ class Chord(Item): self.durationGroup.cacheMinimumNote() return lambda: self._setNoteDuration(note, oldValueForUndo) - def setTupletNearPitch(self, pitch, tupletListForDuration): + def setTupletNearPitch(self, pitch, tupletForDuration): note = self.getNearestNote(pitch) oldValueForUndo = note.duration.copy() - note.duration.tuplets = tupletListForDuration + note.duration.tuplet = tupletForDuration self.durationGroup.cacheMinimumNote() return lambda: self._setNoteDuration(note, oldValueForUndo) @@ -1425,20 +1436,20 @@ class Chord(Item): self.durationGroup.cacheMinimumNote() return lambda: self._setDurationlist(oldValues) - def setTuplet(self, durationTupletListForEachNote): + def setTuplet(self, durationTupletForEachNote:list): """ parameter format: [ - [(2,3)], #note 1 - [(4,5), (2,3)], #note 2 - [(3,4)], #note 3 + (2,3), #note 1 + (4,5), #note 2 + (3,4), #note 3 ] """ - gen = self._createParameterGenerator(self.notelist, durationTupletListForEachNote) + gen = self._createParameterGenerator(self.notelist, durationTupletForEachNote) oldValues = [] for note in self.notelist: oldValues.append(note.duration.copy()) #see _setDurationlist - note.duration.tuplets = next(gen) + note.duration.tuplet = next(gen) self.durationGroup.cacheMinimumNote() return lambda: self._setDurationlist(oldValues) @@ -1697,13 +1708,43 @@ class Chord(Item): "beam" : tuple(), #decided later in track export. Has the same structure as a stem. } - def _lilypond(self): - """Called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """Called by block.lilypond(carryLilypondRanges), returns a string. See Item.lilypond for the general docstring.""" - pitches, durations = zip(*(note.lilypond() for note in self.notelist)) + + def _createLilypondTuplet(duration, carryLilypondRanges:dict)->str: + pre = "" + # \tuplet 3/2 { } + if duration.tuplet: #item has a tuplet... + if "tuplet" in carryLilypondRanges: #... and is part of a group (2nd or more) + if duration.tuplet == carryLilypondRanges["tuplet"]: + pass #this is nothing and the entire reason why we check for group membership. Tuplet-members in ly are just inside a {...} music section. + else: #it just happened that two different tuplet types followed next to each other. end one, start a new tuplet group. + carryLilypondRanges["tuplet"] = duration.tuplet + pre = f" }} \\tuplet {duration.tuplet[1]}/{duration.tuplet[0]} {{ " + else: # ... and is the first of a group + carryLilypondRanges["tuplet"] = duration.tuplet + pre = f"\\tuplet {duration.tuplet[1]}/{duration.tuplet[0]} {{ " + else: + if "tuplet" in carryLilypondRanges: #... and the previous item was a tuplet. Group has ended. + pre = " } " #hopefully this just ends the tuplet :) . Pre is correct, not post. Because we end the tuplet of the previous item. + del carryLilypondRanges["tuplet"] + else: #... and the previous item was also not a tuplet. Most common case. + pass + return pre + + #Create lilypond durations + pitches, durations = zip(*(note.lilypond(carryLilypondRanges) for note in self.notelist)) onlyOneDuration = durations.count(durations[0]) == len(durations) + if onlyOneDuration: - return "<" + " ".join(pitches) + ">" + durations[0] + pre = _createLilypondTuplet(self.notelist[0].duration, carryLilypondRanges) + if len(pitches) == 1: + # The most common case: Just a single note + return pre + pitches[0] + durations[0] #this is a string. + else: + # A simple chord with mulitple pitches but just one duration + return pre + "<" + " ".join(pitches) + ">" + durations[0] else: #TODO: Cache this @@ -1713,7 +1754,7 @@ class Chord(Item): #See http://lilypond.org/doc/v2.18/Documentation/notation/writing-rhythms#scaling-durations table = {} for note in self.notelist: - pitch, duration = note.lilypond() + pitch, duration = note.lilypond(carryLilypondRanges) ticks = note.duration.completeDuration() if not (ticks, duration) in table: table[(ticks, duration)] = list() @@ -1802,9 +1843,9 @@ class Rest(Item): self.duration.toggleTriplet() return lambda: self._setDuration(oldValue) - def setTupletNearPitch(self, pitch, tupletListForDuration): + def setTupletNearPitch(self, pitch, tuplet): oldValue = self.duration.copy() - self.duration.tuplets = tupletListForDuration + self.duration.tuplet = tuplet return lambda: self._setDuration(oldValue) #Apply to selection gets called by different methods. We can just create aliases for Rest @@ -1813,17 +1854,17 @@ class Rest(Item): toggleDot = toggleDotNearPitch toggleTriplet = toggleTripletNearPitch - def setTuplet(self, durationTupletListForEachNote): + def setTuplet(self, durationTupletForEachNote:list): """ parameter format compatible with Chord [ - [(2,3)], #note 1 - [(4,5), (2,3)], #note 2 - [(3,4)], #note 3 + (2,3), #note 1 + (4,5), #note 2 + (3,4), #note 3 ] """ oldValue = self.duration.copy() - self.duration.tuplets = durationTupletListForEachNote[0] + self.duration.tuplet = durationTupletForEachNote[0] #TODO: ? reasoning to only take the first? I think this is right, I just forgot why. return lambda: self._setDuration(oldValue) def calcFlag(self): @@ -1842,7 +1883,7 @@ class Rest(Item): "baseDuration": self.duration.baseDuration, "completeDuration" : dur, "dots" : self.duration.dots, - "tuplets" : self.duration.tuplets, + "tuplet" : self.duration.tuplet, "tickindex" : trackState.tickindex - dur, #because we parse the tickindex after we stepped over the item. "midiBytes" : [], #for compatibility with beaming groups: @@ -1853,13 +1894,13 @@ class Rest(Item): } - def _lilypond(self): - """Called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """Called by block.lilypond(carryLilypondRanges), returns a string. See Item.lilypond for the general docstring.""" if self.lilypondParameters["visible"]: - return "r{}".format(self.duration.lilypond()) + return "r{}".format(self.duration.lilypond(carryLilypondRanges)) else: - return "s{}".format(self.duration.lilypond()) + return "s{}".format(self.duration.lilypond(carryLilypondRanges)) class MultiMeasureRest(Item): def __init__(self, numberOfMeasures): @@ -1960,8 +2001,8 @@ class MultiMeasureRest(Item): self.numberOfMeasures = self.numberOfMeasures / 2 return lambda: self._setNumberOfMeasures(oldValue) - def _lilypond(self): - """Called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """Called by block.lilypond(carryLilypondRanges), returns a string. See Item.lilypond for the general docstring.""" lyduration = "{}/{}".format(self._cachedMetricalInstruction.nominator, duration.baseDurationToTraditionalNumber[self._cachedMetricalInstruction.denominator]) @@ -2154,7 +2195,7 @@ class KeySignature(Item): } - def _lilypond(self): + def _lilypond(self, carryLilypondRanges): def build(step, alteration): #generate a Scheme pair for Lilyponds GUILE interpreter if alteration == -10: return "(" + str(step) + " . ," + "FLAT)" @@ -2292,7 +2333,7 @@ class Clef(Item): #Thats it. A GUI does not need to know anything about the clef except its look because we already deliever note pitches as dots on lines, calculated with the clef. } - def _lilypond(self): + def _lilypond(self, carryLilypondRanges): return "\\clef \"{}\"".format(self.clefString) class TimeSignature(Item): #Deprecated since 1750 @@ -2415,7 +2456,7 @@ class MetricalInstruction(Item): "treeOfInstructions" : self.treeOfInstructions.__repr__(), } - def _lilypond(self): + def _lilypond(self, carryLilypondRanges): """Since metrical instruction can get very complex we rely entirely on the lilypond override functionality. The common api signatures already include these.""" raise RuntimeError("This metrical instruction should have a lilypond-override") @@ -2706,8 +2747,8 @@ class InstrumentChange(Item): "UIstring" : "{}[pr{}{}{}]".format(_nameStr, self.program, _msbStr, _lsbStr), #this is for a UI, possibly a text UI, maybe for simple items of a GUI. Make it as short and unambigious as possible. } - def _lilypond(self): - """called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """called by block.lilypond(carryLilypondRanges), returns a string. Don't create white-spaces yourself, this is done by the structures. When in doubt prefer functionality and robustness over 'beautiful' lilypond syntax.""" if self.shortInstrumentName: @@ -2768,8 +2809,8 @@ class ChannelChange(Item): "UIstring" : f"{self.text}(ch{self.value+1})" if self.text else f"ch{self.value+1}", #this is for a UI, possibly a text UI, maybe for simple items of a GUI. Make it as short and unambigious as possible. } - def _lilypond(self): - """called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """called by block.lilypond(carryLilypondRanges), returns a string. Don't create white-spaces yourself, this is done by the structures. When in doubt prefer functionality and robustness over 'beautiful' lilypond syntax.""" if self.text: @@ -2848,7 +2889,7 @@ class RecordedNote(Item): "UIstring" : "", #this is for a UI, possibly a text UI, maybe for simple items of a GUI. Make it as short and unambigious as possible. } - def _lilypond(self): + def _lilypond(self, carryLilypondRanges): """absolutely not""" return "" @@ -2864,8 +2905,8 @@ class LilypondText(Item): """see Item._secondInit""" super()._secondInit(parentBlock) #Item._secondInit - def _lilypond(self): - """called by block.lilypond(), returns a string. + def _lilypond(self, carryLilypondRanges): + """called by block.lilypond(carryLilypondRanges), returns a string. Don't create white-spaces yourself, this is done by the structures. When in doubt prefer functionality and robustness over 'beautiful' lilypond syntax.""" return self.text diff --git a/engine/main.py b/engine/main.py index 25a1717..75ec2b7 100644 --- a/engine/main.py +++ b/engine/main.py @@ -39,14 +39,14 @@ from .lilypond import fromTemplate class Data(template.engine.sequencer.Score): def __init__(self, parentSession): - super().__init__(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.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 #Metadata has only strings as keys, even the numbers. - self.metaData = {key:"" for key in ("title", "subtitle", "dedication","composer","subsubtitle","instrument","meter","arranger", "poet","piece","opus","copyright","tagline", "subtext")} - self.currentMetronomeTrack = self.tracks[0] #A Laborejo Track, indepedent of currentTrack. The metronome is in self.metronome, set by the template Score. + self.metaData = {key:"" for key in ("title", "subtitle", "dedication","composer","subsubtitle","instrument","meter","arranger", "poet","piece","opus","copyright","tagline", "subtext")} + 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): @@ -56,12 +56,12 @@ class Data(template.engine.sequencer.Score): 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. + #TODO: Measure before trying to improve performance. result = [] for track in self.tracks: result.append(track.duration()) @@ -78,7 +78,7 @@ class Data(template.engine.sequencer.Score): except: print ("Error while trying to get track by id") print (sys.exc_info()[0]) - print (trackId, hex(trackId), Track.allTracks, self.tracks, self.hiddenTracks, self.trackIndex) + print (trackId, hex(trackId), Track.allTracks, self.tracks, [(track.name, id(track)) for track in self.tracks], self.hiddenTracks, self.trackIndex) raise return ret @@ -131,9 +131,9 @@ class Data(template.engine.sequencer.Score): 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. - """ + + We don't use the template-function addTrack. + """ assert trackObject.sequencerInterface assert trackObject.parentData == self self.tracks.insert(atIndex, trackObject) @@ -180,11 +180,11 @@ class Data(template.engine.sequencer.Score): 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()) + + 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() @@ -436,8 +436,8 @@ class Data(template.engine.sequencer.Score): 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? + + 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: @@ -451,7 +451,7 @@ class Data(template.engine.sequencer.Score): #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 @@ -469,7 +469,7 @@ class Data(template.engine.sequencer.Score): 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() + 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) @@ -487,10 +487,11 @@ class Data(template.engine.sequencer.Score): track.toPosition(originalPosition) #has head() in it finalResult.append(result) - - for changedTrId in listOfChangedTrackIds: + + #hier weiter machen. Die ids gehen durcheinander. Es ist nicht nur der assert check. Wenn wir das weglassen kommt das Problem später + 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. @@ -514,7 +515,7 @@ class Data(template.engine.sequencer.Score): result = [] if validSelection: for track in selectedTracksAndItems: - result.append([item.copy() for item, cachedTrackState in track]) + result.append([item.copy() for item, cachedTrackState in track]) if writeInSessionBuffer: self.copyObjectsBuffer = result return result @@ -618,10 +619,10 @@ class Data(template.engine.sequencer.Score): 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.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? @@ -637,7 +638,7 @@ class Data(template.engine.sequencer.Score): """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. """ @@ -707,7 +708,7 @@ class Data(template.engine.sequencer.Score): #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): @@ -852,7 +853,7 @@ class Data(template.engine.sequencer.Score): #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: @@ -968,7 +969,7 @@ class Data(template.engine.sequencer.Score): 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. + #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) @@ -1019,17 +1020,17 @@ class Data(template.engine.sequencer.Score): 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() + listOfBlockIds = track.asListOfBlockIds() for block in track.blocks: - if not block.data: + 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: @@ -1051,14 +1052,14 @@ class Data(template.engine.sequencer.Score): #Save / Load / Export def serialize(self)->dict: - dictionary = super().serialize() + dictionary = super().serialize() dictionary["class"] = self.__class__.__name__ 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) + dictionary["currentMetronomeTrackIndex"] = self.tracks.index(self.currentMetronomeTrack) return dictionary @classmethod @@ -1066,17 +1067,17 @@ class Data(template.engine.sequencer.Score): assert cls.__name__ == serializedData["class"] self = cls.__new__(cls) super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap - self.parentSession = parentSession + 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"] - self.currentMetronomeTrack = self.tracks[serializedData["currentMetronomeTrackIndex"]] + 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, diff --git a/engine/track.py b/engine/track.py index 46205a4..0cbd54c 100644 --- a/engine/track.py +++ b/engine/track.py @@ -815,7 +815,13 @@ class Track(object): #Save / Load / Export def lilypond(self): - """Called by score.lilypond(), returns a string.""" + """Called by score.lilypond(), returns a string. + + We carry a dict around to hold lilypond on/off markers like tuplets + that need to set as ranges. + + + """ for item in self.blocks[0].data[:4]: if type(item) is MetricalInstruction: timeSig = "" @@ -825,7 +831,9 @@ class Track(object): upbeatLy = "\\partial {} ".format(Duration.createByGuessing(self.upbeatInTicks).lilypond()) if self.upbeatInTicks else "" - data = " ".join(block.lilypond() for block in self.blocks) + carryLilypondRanges = {} #handed from item to item for ranges such as tuplets. Can act like a stack or simply remember stuff. + + data = " ".join(block.lilypond(carryLilypondRanges) for block in self.blocks) if data: return timeSig + upbeatLy + data else: diff --git a/qtgui/items.py b/qtgui/items.py index 8a04df6..9c7cfd8 100644 --- a/qtgui/items.py +++ b/qtgui/items.py @@ -280,14 +280,15 @@ class GuiNote(QtWidgets.QGraphicsItem): d.setPos((dot+1)*4+6, constantsAndConfigs.stafflineGap * noteExportObject["dotOnLine"] / 2) d.setParentItem(self) - for i, (upper, lower) in enumerate(noteExportObject["tuplets"]): - tuplet = GuiTupletNumber(upper, lower) + #Draw the tuplet. There are no nested tuplets in Laborejo. + if noteExportObject["tuplet"]: + upper, lower = noteExportObject["tuplet"] + tuplet = GuiTupletNumber(upper, lower) if directionRightAndUpwards: - tuplet.setPos(3, -6*(i+1)) + tuplet.setPos(3, -6) else: - tuplet.setPos(3, 6*(i+1)) - + tuplet.setPos(3, 6) tuplet.setParentItem(self.noteHead) if noteExportObject["durationKeyword"]: @@ -607,10 +608,11 @@ class GuiRest(GuiItem): d.setPos((dot+1)*4+6, -2) d.setParentItem(self) - for i, (upper, lower) in enumerate(self.staticItem["tuplets"]): + if self.staticItem["tuplet"]: + upper, lower = self.staticItem["tuplet"] tuplet = GuiTupletNumber(upper, lower) tuplet.setParentItem(self) - tuplet.setPos(2, (i+1)*-4-constantsAndConfigs.stafflineGap*2) + tuplet.setPos(2, -4-constantsAndConfigs.stafflineGap*2) class GuiKeySignature(GuiItem): def __init__(self, staticItem): diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index 875346a..0ac2b9d 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -128,6 +128,8 @@ class MainWindow(TemplateMainWindow): #Here is the crowbar-method. self.nsmClient.announceSaveStatus(isClean = True) + api.connectModMidiMerger() + def zoom(self, scaleFactor:float): """Scale factor is absolute""" @@ -144,7 +146,7 @@ class MainWindow(TemplateMainWindow): except: print (c) if i: - ly = i.lilypond() + ly = i.lilypond(carryLilypondRanges = {}) if (not ly) or len(ly) > 13: ly = "" else: diff --git a/qtgui/musicstructures.py b/qtgui/musicstructures.py index 3ea61e9..e35dd26 100644 --- a/qtgui/musicstructures.py +++ b/qtgui/musicstructures.py @@ -47,21 +47,21 @@ class GuiBlockHandle(QtWidgets.QGraphicsRectItem): """A simplified version of a Block. Since we don't use blocks in the GUI, only in the backend we still need them sometimes as macro strutures, where we don't care about the content. This is the transparent Block handle that appears when the user uses the mouse to drag and drop - a block with shift + middle mouse button. + a block. It is visible all the time though and can be clicked on. In opposite to the background color, which is just a color and stays in place. """ def __init__(self, parent, staticExportItem, x, y, w, h): super().__init__(x, y, w, h) - self.setFlag(QtWidgets.QGraphicsItem.ItemHasNoContents, True) #only child items. Without this we get notImplementedError: QGraphicsItem.paint() is abstract and must be overridden - self.setFlag(QtWidgets.QGraphicsItem.ItemContainsChildrenInShape, True) + #self.setFlag(QtWidgets.QGraphicsItem.ItemHasNoContents, True) #only child items. Without this we get notImplementedError: QGraphicsItem.paint() is abstract and must be overridden + #self.setFlag(QtWidgets.QGraphicsItem.ItemContainsChildrenInShape, True) self.parent = parent #GuiTrack instance - self.color = None #inserted by the creating function in GuiTrack + self.color = None #QColor inserted by the creating function in GuiTrack. Used during dragging, then reset to transparent. self.trans = QtGui.QColor("transparent") self.setPen(self.trans) self.setBrush(self.trans) - self.setOpacity(0.4) #slightly fuller than background + #self.setOpacity(0.4) #slightly fuller than background self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) self.setParentItem(parent) self.setZValue(10) #This is the z value within GuiTrack @@ -79,20 +79,23 @@ class GuiBlockHandle(QtWidgets.QGraphicsRectItem): self.idText.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) """ - if self.staticExportItem["completeDuration"] >= api.D1: #cosmetics - self.startLabel = QtWidgets.QGraphicsSimpleTextItem(self.staticExportItem["name"] + translate("musicstructures", " start")) - self.startLabel.setParentItem(self) - self.startLabel.setPos(0, constantsAndConfigs.stafflineGap) - self.startLabel.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) - + if self.staticExportItem["completeDuration"] >= 3 * api.D1: #cosmetics + self.startLabel = QtWidgets.QGraphicsSimpleTextItem(self.staticExportItem["name"]) self.endLabel = QtWidgets.QGraphicsSimpleTextItem(self.staticExportItem["name"] + translate("musicstructures", " end ")) - self.endLabel.setParentItem(self) - self.endLabel.setPos(self.rect().width() - self.endLabel.boundingRect().width(), constantsAndConfigs.stafflineGap) - self.endLabel.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) else: self.startLabel = QtWidgets.QGraphicsSimpleTextItem("") self.endLabel = QtWidgets.QGraphicsSimpleTextItem("") + self.startLabel.setParentItem(self) + self.startLabel.setPos(0, constantsAndConfigs.stafflineGap) + self.startLabel.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) + + self.endLabel.setParentItem(self) + self.endLabel.setPos(self.rect().width() - self.endLabel.boundingRect().width(), constantsAndConfigs.stafflineGap) + self.endLabel.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) + + + def stretchXCoordinates(self, factor): """Reposition the items on the X axis. Call goes through all parents/children, starting from ScoreView._stretchXCoordinates. @@ -108,40 +111,23 @@ class GuiBlockHandle(QtWidgets.QGraphicsRectItem): self.startLabel.hide() self.endLabel.hide() - def mouseMoveEventCustom(self, event): - # All the positions below don't work. They work fine when dragging Tracks around but not this Item. I can't be bothered to figure out why. - #scenePos() results ins an item position that is translated down and right. The higher the x/y value the more the offset - #Instead we calculate our delta ourselves. - - #self.setPos(self.mapToItem(self, event.scenePos())) - #self.setPos(self.mapFromScene(event.scenePos())) - #posGlobal = QtGui.QCursor.pos() - #posView = self.parent.parentScore.parentView.mapFromGlobal(posGlobal) #a widget - #posScene = self.parent.parentScore.parentView.mapToScene(posView) - #print (posGlobal, posView, posScene, event.scenePos()) - - """ - #Does not work with zooming. - if self.cursorPosOnMoveStart: - delta = QtGui.QCursor.pos() - self.cursorPosOnMoveStart - new = self.posBeforeMove + delta - self.setPos(new) - """ - if self.cursorPosOnMoveStart: - self.setPos(event.scenePos()) - super().mouseMoveEvent(event) - def mousePressEventCustom(self, event): + """Not a qt-override. This is called directly by GuiScore + if you click on a block with the right modifier keys (none)""" self.posBeforeMove = self.pos() self.cursorPosOnMoveStart = QtGui.QCursor.pos() self.setBrush(self.color) + self.endLabel.hide() super().mousePressEvent(event) def mouseReleaseEventCustom(self, event): + """Not a qt-override. This is called directly by GuiScore + if you click-release on a block""" self.setBrush(self.trans) self.setPos(self.posBeforeMove) #In case the block was moved to a position where no track is (below the tracks) we just reset the graphics. self.posBeforeMove = None self.cursorPosOnMoveStart = None + self.endLabel.show() super().mouseReleaseEvent(event) def contextMenuEvent(self, event): @@ -149,7 +135,7 @@ class GuiBlockHandle(QtWidgets.QGraphicsRectItem): listOfLabelsAndFunctions = [ (translate("musicstructures", "edit properties"), lambda: BlockPropertiesEdit(self.scene().parentView.mainWindow, staticExportItem = self.staticExportItem)), ("separator", None), - #("split here", lambda: self.splitHere(event)), + #("split here", lambda: self.splitHere(event)), #Impossible because we can't see notes. (translate("musicstructures", "duplicate"), lambda: api.duplicateBlock(self.staticExportItem["id"])), (translate("musicstructures", "create content link"), lambda: api.duplicateContentLinkBlock(self.staticExportItem["id"])), (translate("musicstructures", "unlink"), lambda: api.unlinkBlock(self.staticExportItem["id"])), @@ -294,7 +280,7 @@ class GuiTrack(QtWidgets.QGraphicsItem): bgItem.setZValue(-10) #This is the z value within GuiTrack bgItem.setEnabled(False) - transparentBlockHandle = GuiBlockHandle(self, block, 0, -2 * constantsAndConfigs.stafflineGap, block["completeDuration"] / constantsAndConfigs.ticksToPixelRatio, h + 4 * constantsAndConfigs.stafflineGap) #x, y, w, h + transparentBlockHandle = GuiBlockHandle(self, block, 0, -2 * constantsAndConfigs.stafflineGap, block["completeDuration"] / constantsAndConfigs.ticksToPixelRatio, h-constantsAndConfigs.stafflineGap) #x, y, w, h transparentBlockHandle.color = color self.transparentBlockHandles.append(transparentBlockHandle) transparentBlockHandle.setPos(block["tickindex"] / constantsAndConfigs.ticksToPixelRatio, -2*constantsAndConfigs.stafflineGap) diff --git a/qtgui/scorescene.py b/qtgui/scorescene.py index 3072b86..e110879 100644 --- a/qtgui/scorescene.py +++ b/qtgui/scorescene.py @@ -339,21 +339,21 @@ class GuiScore(QtWidgets.QGraphicsScene): def mousePressEvent(self, event): """Pressing the mouse button is the first action of drag and drop. We make the mouse cursor invisible so the user - can see where the point is going - - When in blockmode pressing the middle button combined with either Alt or Shift moves tracks and blocks. - + can see where the object is moving """ - if event.button() == 4 and self.parentView.mode() in ("block", "cc"): # Middle Button + if event.button() == QtCore.Qt.LeftButton and self.parentView.mode() in ("block", "cc"): modifiers = QtWidgets.QApplication.keyboardModifiers() - if modifiers == QtCore.Qt.ShiftModifier: #block move + + #Block Move + if modifiers == QtCore.Qt.NoModifier: block = self.blockAt(event.scenePos()) if block: #works for note blocks and conductor blocks block.staticExportItem["guiPosStart"] = block.pos() #without shame we hijack the backend-dict. self.duringBlockDragAndDrop = block block.mousePressEventCustom(event) - elif modifiers == QtCore.Qt.AltModifier and self.parentView.mode() == "block": #track move + #Track Move + elif (modifiers == QtCore.Qt.AltModifier or modifiers == QtCore.Qt.ShiftModifier ) and self.parentView.mode() == "block": track = self.trackAt(event.scenePos()) if track and not track is self.conductor: self.parentView.setCursor(QtCore.Qt.BlankCursor) @@ -370,11 +370,13 @@ class GuiScore(QtWidgets.QGraphicsScene): it will not get mouseRelease or mouseMove events. MousePress always works.""" if self.duringTrackDragAndDrop: + #X is locked for tracks. x = self.duringTrackDragAndDrop.staticExportItem["guiPosStart"].x() y = event.scenePos().y() self.duringTrackDragAndDrop.setPos(x, y) elif self.duringBlockDragAndDrop: - self.duringBlockDragAndDrop.mouseMoveEventCustom(event) + self.duringBlockDragAndDrop.setPos(event.scenePos()) + #self.duringBlockDragAndDrop.mouseMoveEventCustom(event) super().mouseMoveEvent(event) @@ -493,7 +495,7 @@ class GuiScore(QtWidgets.QGraphicsScene): elif ccBlock: #TODO: Also different CC in the same GuiTrack api.moveCCBlockToOtherTrack(dragBlockId, targetTrack.parentDataTrackId, newBlockOrder) - elif event.button() == 1: #a positional mouse left click in a note-track + elif self.parentView.mode() == "notation" and (not (tempBlockDragAndDrop or tempTrackDragAndDrop)) and event.button() == 1: #a positional mouse left click in a note-track, but only it not drag-drop release. track = self.trackAt(event.scenePos()) if track and not track is self.conductor: modifiers = QtWidgets.QApplication.keyboardModifiers()