diff --git a/engine/api.py b/engine/api.py index ad9f9d1..bd8f7d3 100644 --- a/engine/api.py +++ b/engine/api.py @@ -1,12 +1,21 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- +#Standard Library Modules +from typing import List, Set, Dict, 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: from template.engine.api import * -DEFAULT_VELOCITY = 90 -DEFAULT_FACTOR = 1 -NUMBER_OF_STEPS = 8 #for exchange with the GUI: one octave of range per pattern. This is unlikely to change and then will not work immediately, but better to keep it as a named "constant". +#Our modules +from .pattern import NUMBER_OF_STEPS + +DEFAULT_FACTOR = 1 #for the GUI. + #New callbacks class ClientCallbacks(Callbacks): #inherits from the templates api callbacks @@ -16,7 +25,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks self.timeSignatureChanged = [] self.scoreChanged = [] self.numberOfMeasuresChanged = [] - self.numberOfTracksChanged = [] + self.trackStructureChanged = [] self.trackMetaDataChanged = [] self.patternChanged = [] self.stepChanged = [] @@ -30,16 +39,16 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks """There is one tempo for the entire song in quarter notes per mintue. score.isTransportMaster to False means we do not create our own changes and leave everything to the default. Negative values are not possible""" - if session.data.isTransportMaster: - export = session.data.quarterNotesPerMinute + if session.data.tempoMap.isTransportMaster: + export = session.data.tempoMap.getQuarterNotesPerMinute() else: export = None for func in self.quarterNotesPerMinuteChanged: func(export) - def _setPlaybackTicks(self): + def _setPlaybackTicks(self): #Differs from the template because it has subdivisions. ppqn = cbox.Transport.status().pos_ppqn * session.data.subdivisions - status = _playbackStatus() + status = playbackStatus() for func in self.setPlaybackTicks: func(ppqn, status) @@ -117,6 +126,13 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks for func in self.stepChanged: func(index, pitch) + def _trackStructureChanged(self, track): + """update one track structure. Does not export cbox. + Also includes transposition """ + export = track.export() + for func in self.trackStructureChanged: + func(export) + def _numberOfTracksChanged(self): """sent the current track order as list of ids, combined with their structure. This is also used when tracks get created or deleted, also on initial load. @@ -138,6 +154,9 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks for func in self.numberOfMeasuresChanged: func(export) + + + #Inject our derived Callbacks into the parent module template.engine.api.callbacks = ClientCallbacks() from template.engine.api import callbacks @@ -162,7 +181,8 @@ def startEngine(nsmClient): callbacks._trackMetaDataChanged(track) #for colors, scale and noteNames session.data.buildAllTracks() - _updatePlayback() + + updatePlayback() def toggleLoop(): @@ -170,17 +190,17 @@ def toggleLoop(): Current measure is where the playback cursor is""" if session.inLoopMode: session.data.buildSongDuration() #no parameter removes the loop - _updatePlayback() + updatePlayback() session.inLoopMode = None callbacks._loopChanged(None, None, None) else: now = loopMeasureAroundPpqn=cbox.Transport.status().pos_ppqn loopStart, loopEnd = session.data.buildSongDuration(now) - _updatePlayback() + updatePlayback() session.inLoopMode = (loopStart, loopEnd) assert loopStart <= now < loopEnd - if not _playbackStatus(): + if not playbackStatus(): cbox.Transport.play() oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions @@ -204,17 +224,17 @@ def toStart(): ##Score def set_quarterNotesPerMinute(value): if value is None: - session.data.isTransportMaster = False #triggers rebuild + session.data.tempoMap.isTransportMaster = False #triggers rebuild elif value == "on": - assert not session.data.isTransportMaster + assert not session.data.tempoMap.isTransportMaster #keep old bpm value - session.data.isTransportMaster = True #triggers rebuild + session.data.tempoMap.isTransportMaster = True #triggers rebuild else: assert value > 0 - session.data.quarterNotesPerMinute = value #triggers rebuild - session.data.isTransportMaster = True #triggers rebuild + session.data.tempoMap.setQuarterNotesPerMinute(value) + session.data.tempoMap.isTransportMaster = True #triggers rebuild #Does not need track rebuilding - _updatePlayback() + updatePlayback() callbacks._quarterNotesPerMinuteChanged() @@ -222,21 +242,21 @@ def set_whatTypeOfUnit(ticks): if session.data.whatTypeOfUnit == ticks: return session.data.whatTypeOfUnit = ticks session.data.buildAllTracks() - _updatePlayback() + updatePlayback() callbacks._timeSignatureChanged() def set_howManyUnits(value): if session.data.howManyUnits == value: return session.data.howManyUnits = value session.data.buildAllTracks() - _updatePlayback() + updatePlayback() callbacks._timeSignatureChanged() def set_subdivisions(value): if session.data.subdivisions == value: return session.data.subdivisions = value session.data.buildAllTracks() - _updatePlayback() + updatePlayback() callbacks._subdivisionsChanged() def convert_subdivisions(value, errorHandling): @@ -245,7 +265,7 @@ def convert_subdivisions(value, errorHandling): result = session.data.convertSubdivisions(value, errorHandling) if result: session.data.buildAllTracks() - _updatePlayback() + updatePlayback() callbacks._timeSignatureChanged() #includes subdivisions for tr in session.data.tracks: callbacks._patternChanged(tr) @@ -257,7 +277,7 @@ def set_numberOfMeasures(value): if session.data.numberOfMeasures == value: return session.data.numberOfMeasures = value session.data.buildSongDuration() - _updatePlayback() + updatePlayback() callbacks._numberOfMeasuresChanged() callbacks._scoreChanged() #redundant but cheap and convenient @@ -269,7 +289,7 @@ def set_measuresPerGroup(value): def changeTrackName(trackId, name): track = session.data.trackById(trackId) - track.name = " ".join(name.split()) + track.sequencerInterface.name = " ".join(name.split()) callbacks._trackMetaDataChanged(track) def changeTrackColor(trackId, colorInHex): @@ -291,11 +311,11 @@ def createSiblingTrack(trackId): (if any)""" track = session.data.trackById(trackId) assert type(track.pattern.scale) == tuple - newTrack = session.data.addTrack(name=track.name, scale=track.pattern.scale, color=track.color, noteNames=track.pattern.noteNames) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2" + newTrack = session.data.addTrack(name=track.sequencerInterface.name, scale=track.pattern.scale, color=track.color, noteNames=track.pattern.noteNames) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2" newTrack.pattern.averageVelocity = track.pattern.averageVelocity - jackConnections = cbox.JackIO.get_connected_ports(track.cboxPortName()) + jackConnections = cbox.JackIO.get_connected_ports(track.sequencerInterface.cboxPortName()) for port in jackConnections: - cbox.JackIO.port_connect(newTrack.cboxPortName(), port) + cbox.JackIO.port_connect(newTrack.sequencerInterface.cboxPortName(), port) #Move new track to neighbour the old one. oldIndex = session.data.tracks.index(track) newIndex = session.data.tracks.index(newTrack) @@ -310,7 +330,7 @@ def deleteTrack(trackId): session.data.deleteTrack(track) if not session.data.tracks: #always keep at least one track session.data.addTrack() - _updatePlayback() + updatePlayback() callbacks._numberOfTracksChanged() def moveTrack(trackId, newIndex): @@ -331,7 +351,7 @@ def setSwitches(trackId, setOfPositions, newBool): else: track.structure = track.structure.difference(setOfPositions) #remove everything from setOfPositions that is in the existing one, ignore entries from setOfPositions not in existing. track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) def setSwitch(trackId, position, newBool): @@ -343,7 +363,7 @@ def setSwitch(trackId, position, newBool): if not position in track.structure: return track.structure.remove(position) track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) return True @@ -359,21 +379,21 @@ def trackInvertSwitches(trackId): new = set(i for i in range(session.data.numberOfMeasures)) track.structure = new.difference(track.structure) track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) def trackOffAllSwitches(trackId): track = session.data.trackById(trackId) track.structure = set() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) def trackOnAllSwitches(trackId): track = session.data.trackById(trackId) track.structure = set(i for i in range(session.data.numberOfMeasures)) track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) def trackMergeCopyFrom(sourceTrackId, targetTrackId): @@ -384,7 +404,7 @@ def trackMergeCopyFrom(sourceTrackId, targetTrackId): targetTrack.whichPatternsAreScaleTransposed.update(sourceTrack.whichPatternsAreScaleTransposed) targetTrack.whichPatternsAreHalftoneTransposed.update(sourceTrack.whichPatternsAreHalftoneTransposed) targetTrack.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(targetTrack) def setSwitchScaleTranspose(trackId, position, transpose): @@ -392,7 +412,7 @@ def setSwitchScaleTranspose(trackId, position, transpose): track = session.data.trackById(trackId) track.whichPatternsAreScaleTransposed[position] = transpose track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) return True @@ -401,7 +421,7 @@ def setSwitchHalftoneTranspose(trackId, position, transpose): track = session.data.trackById(trackId) track.whichPatternsAreHalftoneTransposed[position] = transpose track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackStructureChanged(track) return True @@ -413,7 +433,7 @@ def insertSilence(howMany, beforeMeasureNumber): track.whichPatternsAreHalftoneTransposed = { (k+howMany if k >= beforeMeasureNumber else k):v for k,v in track.whichPatternsAreHalftoneTransposed.items() } callbacks._trackStructureChanged(track) session.data.buildAllTracks() - _updatePlayback() + updatePlayback() def deleteSwitches(howMany, fromMeasureNumber): for track in session.data.tracks: @@ -447,7 +467,7 @@ def deleteSwitches(howMany, fromMeasureNumber): callbacks._trackStructureChanged(track) session.data.buildAllTracks() - _updatePlayback() + updatePlayback() #Pattern Steps def setPattern(trackId, patternList): @@ -479,7 +499,7 @@ def setStep(trackId, stepExportDict): track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._stepChanged(track, stepExportDict) def removeStep(trackId, index, pitch): @@ -489,7 +509,7 @@ def removeStep(trackId, index, pitch): track.pattern.data.remove(oldNote) track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._removeStep(track, index, pitch) def setScale(trackId, scale): @@ -499,7 +519,7 @@ def setScale(trackId, scale): track.pattern.scale = scale track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackMetaDataChanged(track) def setNoteNames(trackId, noteNames): @@ -514,7 +534,7 @@ def transposeHalftoneSteps(trackId, steps): track.pattern.scale = [midipitch+steps for midipitch in track.pattern.scale] track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackMetaDataChanged(track) def patternInvertSteps(trackId): @@ -522,7 +542,7 @@ def patternInvertSteps(trackId): track.pattern.invert() track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._patternChanged(track) def patternOnAllSteps(trackId): @@ -530,7 +550,7 @@ def patternOnAllSteps(trackId): track.pattern.fill() track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._patternChanged(track) def patternOffAllSteps(trackId): @@ -538,7 +558,7 @@ def patternOffAllSteps(trackId): track.pattern.empty() track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._patternChanged(track) @@ -583,7 +603,7 @@ def setScaleToKeyword(trackId, keyword): track.pattern.scale = result track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._trackMetaDataChanged(track) def changePatternVelocity(trackId, steps): @@ -594,7 +614,7 @@ def changePatternVelocity(trackId, steps): track.pattern.buildExportCache() track.buildTrack() - _updatePlayback() + updatePlayback() callbacks._patternChanged(track) @@ -603,11 +623,11 @@ def changePatternVelocity(trackId, steps): def noteOn(trackId, row): track = session.data.trackById(trackId) midipitch = track.pattern.scale[row] - cbox.send_midi_event(0x90, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutUuid) + cbox.send_midi_event(0x90, midipitch, track.pattern.averageVelocity, output=track.sequencerInterface.cboxMidiOutUuid) def noteOff(trackId, row): track = session.data.trackById(trackId) midipitch = track.pattern.scale[row] - cbox.send_midi_event(0x80, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutUuid) + cbox.send_midi_event(0x80, midipitch, track.pattern.averageVelocity, output=track.sequencerInterface.cboxMidiOutUuid) diff --git a/engine/main.py b/engine/main.py index d824000..63ee33a 100644 --- a/engine/main.py +++ b/engine/main.py @@ -30,6 +30,8 @@ from calfbox import cbox #Template Modules from template.engine.data import Data as TemplateData import template.engine.sequencer +from template.engine.duration import D4 +from template.engine.pitch import noteNames #Our modules from .track import Track @@ -47,36 +49,31 @@ class Data(template.engine.sequencer.Score): traditional time signatures. """ - def __init__(self, parentSession, howManyUnits=8, whatTypeOfUnit=D4, tracks = None, numberOfMeasures = 64, measuresPerGroup=8, subdivisions=1, isTransportMaster=False, quarterNotesPerMinute=120.0): - super().__init__(parentSession, tracks, tempoMap=None) - self.howManyUnits = howManyUnits - self.whatTypeOfUnit = whatTypeOfUnit - self.numberOfMeasures = numberOfMeasures - self.measuresPerGroup = measuresPerGroup # meta data, has no effect on playback. - self.subdivisions = subdivisions + def __init__(self, parentSession): + super().__init__(parentSession) + self.howManyUnits = 8 + self.whatTypeOfUnit = D4 + self.numberOfMeasures = 64 + self.measuresPerGroup = 8 # meta data, has no effect on playback. + self.subdivisions = 1 self.lastUsedNotenames = noteNames["English"] #The default value for new tracks/patterns. Changed each time the user picks a new representation via api.setNoteNames . noteNames are saved with the patterns. - if not tracks: #Empty / New project - self.tracks = [] - self.addTrack(name="Melody A", color="#ffff00") - self.tracks[0].structure=set((0,)) #Already have the first pattern activated, so 'play' after startup already produces sounding notes. This is less confusing for a new user. - self.addTrack(name="Bass A", color="#00ff00") - self.addTrack(name="Drums A", color="#ff5500") - - def trackById(self, trackId): - for track in self.tracks: - if trackId == id(track): - return track - raise ValueError(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}") + + self.addTrack(name="Melody A", color="#ffff00") + self.tracks[0].structure=set((0,)) #Already have the first pattern activated, so 'play' after startup already produces sounding notes. This is less confusing for a new user. + self.addTrack(name="Bass A", color="#00ff00") + self.addTrack(name="Drums A", color="#ff5500") + + self._processAfterInit() + + def _processAfterInit(self): + pass def addTrack(self, name="", scale=None, color=None, noteNames=None): + """Overrides the simpler template version""" track = Track(parentScore=self, name=name, scale=scale, color=color, noteNames=noteNames) self.tracks.append(track) return track - def deleteTrack(self, track): - track.prepareForDeletion() - self.tracks.remove(track) - def convertSubdivisions(self, value, errorHandling): """Not only setting the subdivisions but also trying to scale existing notes up or down proportinally. But only if possible.""" @@ -177,43 +174,24 @@ class Data(template.engine.sequencer.Score): "numberOfMeasures" : self.numberOfMeasures, "measuresPerGroup" : self.measuresPerGroup, "subdivisions" : self.subdivisions, + "lastUsedNotenames" : self.lastUsedNotenames, }) return dictionary @classmethod def instanceFromSerializedData(cls, parentSession, serializedData): - self = cls(parentSession=parentSession, - howManyUnits=serializedData["howManyUnits"], - whatTypeOfUnit=serializedData["whatTypeOfUnit"], - tracks=True, - numberOfMeasures=serializedData["numberOfMeasures"], - measuresPerGroup=serializedData["measuresPerGroup"], - subdivisions=serializedData["subdivisions"], - isTransportMaster=serializedData["isTransportMaster"], - quarterNotesPerMinute=serializedData["quarterNotesPerMinute"], - ) - - self.tracks = [] - for trackSrzData in serializedData["tracks"]: - track = Track( - parentScore=self, - name=trackSrzData["name"], - structure=set(trackSrzData["structure"]), - pattern= True, #fake. Filled in right after Track got created - color=trackSrzData["color"], - whichPatternsAreScaleTransposed=trackSrzData["whichPatternsAreScaleTransposed"], - whichPatternsAreHalftoneTransposed=trackSrzData["whichPatternsAreHalftoneTransposed"], - ) - - track.pattern = Pattern(parentTrack=track, data=trackSrzData["data"], scale=trackSrzData["scale"], noteNames=trackSrzData["noteNames"],) - self.tracks.append(track) - return self - - @classmethod - def instanceFromSerializedData(cls, parentSession, serializedData): - self = super().instanceFromSerializedData(cls, parentSession, serializedData) + self = cls.__new__(cls) + self.howManyUnits = serializedData["howManyUnits"] + self.whatTypeOfUnit = serializedData["whatTypeOfUnit"] + self.numberOfMeasures = serializedData["numberOfMeasures"] + self.measuresPerGroup = serializedData["measuresPerGroup"] + self.subdivisions = serializedData["subdivisions"] + self.lastUsedNotenames = serializedData["lastUsedNotenames"] + #Tracks depend on the rest of the data already in place because they create a cache on creation. + super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap + return self def export(self): return { @@ -222,6 +200,7 @@ class Data(template.engine.sequencer.Score): "whatTypeOfUnit" : self.whatTypeOfUnit, "numberOfMeasures" : self.numberOfMeasures, "measuresPerGroup" : self.measuresPerGroup, + "subdivisions" : self.subdivisions, "isTransportMaster" : self.tempoMap.export()["isTransportMaster"], } diff --git a/engine/pattern.py b/engine/pattern.py new file mode 100644 index 0000000..97fb2de --- /dev/null +++ b/engine/pattern.py @@ -0,0 +1,242 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2018, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +This file is part of Patroneo ( https://www.laborejo.org ) + +Laborejo 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 . +""" + + +#Standard Library Modules +from typing import List, Set, Dict, Tuple +from warnings import warn + +#Third Party Modules +from calfbox import cbox + +NUMBER_OF_STEPS = 8 #for exchange with the GUI: one octave of range per pattern. This is unlikely to change and then will not work immediately, but better to keep it as a named "constant". +DEFAULT_VELOCITY = 90 + +class Pattern(object): + """A pattern can be in only one track. + In fact having it as its own object is only for code readability + + A pattern is an unordered list of dicts. Each dicts is an step, or a note. + {"index":int 0-timesigLength, "factor": float, "pitch", int 0-7, "velocity":int 0-127} + + The pitch is determined is by a scale, which is a list of len 7 of midi pitches. Our "pitch" is + an index in this list. + The scale works in screen coordinates(rows and columns). So usually the highest values comes first. + + It is not possible to create overlapping sounds with different pitches. + We do force the correct order of on and off for the same pitch by cutting off the previous + note. + + The pattern has always its maximum length. + + """ + def __init__(self, parentTrack, data:List[dict]=None, scale:Tuple[int]=None, noteNames:List[str]=None): + self._prepareBeforeInit() + self.parentTrack = parentTrack + self.scale = scale if scale else (72, 71, 69, 67, 65, 64, 62, 60) #Scale needs to be set first because on init/load data already depends on it, at least the default scale. The scale is part of the track meta callback. + self.data = data if data else list() #For content see docstring. this cannot be the default parameter because we would set the same list for all instances. + self.noteNames = noteNames if noteNames else self.parentTrack.parentScore.lastUsedNotenames #This is mostly for the GUI or other kinds of representation instead midi notes + self._processAfterInit() + + def _prepareBeforeInit(self): + self._cachedTransposedScale = {} + + def _processAfterInit(self): + self._tonalRange = range(-1*NUMBER_OF_STEPS+1, NUMBER_OF_STEPS) + self.averageVelocity = DEFAULT_VELOCITY # cached on each build + + self._exportCacheVersion = 0 # increased each time the cache is renewed. Can be used to check for changes in the pattern itself. + self.exportCache = [] #filled in by self.buildExportCache. Used by parentTrack.export() to send to the GUI + self.buildExportCache() + + self._builtPatternCache = {} #holds a ready cbox pattern for a clip as value. Key is a tuple of hashable parameters. see self.buildPattern + + @property + def scale(self): + return self._scale + + @scale.setter + def scale(self, value): + """The scale can never be modified in place! Only replace it with a different list. + For that reason we keep scale an immutable tuple instead of a list""" + self._scale = tuple(value) + self.createCachedTonalRange() + + @property + def noteNames(self): + return self._noteNames + + @noteNames.setter + def noteNames(self, value): + self._noteNames = tuple(value) #we keep it immutable, this is safer to avoid accidental linked structures when creating a clone. + self.parentTrack.parentScore.lastUsedNotenames = self._noteNames #new default for new tracks + + def fill(self): + """Create a 2 dimensional array""" + l = len(self.scale) + lst = [] + vel = self.averageVelocity + for index in range(self.parentTrack.parentScore.howManyUnits): + for pitchindex in range(l): + lst.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel}) + self.data = lst + + def empty(self): + self.data = [] + + def invert(self): + l = len(self.scale) + lst = [] + existing = [(d["index"], d["pitch"]) for d in self.data] + vel = self.averageVelocity + for index in range(self.parentTrack.parentScore.howManyUnits): + for pitchindex in range(l): + if not (index, pitchindex) in existing: + lst.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel}) + self.data = lst + + def stepByIndexAndPitch(self, index, pitch): + for d in self.data: + if d["index"] == index and d["pitch"] == pitch: + return d + return None + + def createCachedTonalRange(self): + """Take the existing scale and generate all possible notes for it. This way we don't + need to regenerate them each time the pattern change. + This is meant to return a midi pitch. However, not all 128 are possible.""" + + #We create three full octaves because the code is much easier to write and read. no modulo, no divmod. + #We may not present all of them to the user. + self._cachedTransposedScale.clear() + for step in range(NUMBER_OF_STEPS): + self._cachedTransposedScale[step] = self.scale[step] + if not step+7 in self._cachedTransposedScale: + self._cachedTransposedScale[step+7] = self.scale[step] - 12 #yes, that is correct. We do top-bottom for our steps. + if not step-7 in self._cachedTransposedScale: + self._cachedTransposedScale[step-7] = self.scale[step] + 12 + + def buildExportCache(self): + """Called by the api directly and once on init/load""" + self.exportCache = [] #only used by parentTrack.export() + assert self.scale and len(self.scale) == NUMBER_OF_STEPS #from constants + + for pattern in (p for p in self.data if p["index"] < self.parentTrack.parentScore.howManyUnits): # < and not <= because index counts from 0 but howManyUnits counts from 1 + note = {} + note["pitch"] = pattern["pitch"] + note["index"] = pattern["index"] + note["factor"] = pattern["factor"] + note["velocity"] = pattern["velocity"] + note["midipitch"] = self.scale[pattern["pitch"]] #we always report the untransposed note. For a GUI the pattern gets transposed, not the note. + note["exceedsPlayback"] = False #This is set by buildPattern. + self.exportCache.append(note) + + self.averageVelocity = int( sum((n["velocity"] for n in self.data)) / len(self.data) ) if self.data else DEFAULT_VELOCITY + self._exportCacheVersion += 1 #used by build pattern for its cache hash + + def buildPattern(self, scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions): + """return a cbox pattern ready to insert into a cbox clip. + This is the function to communicate with the outside, e.g. the track. + + All ticks are relativ to the pattern start + + We cache internally, but for the outside it looks like we generate a new pattern each time + on each little note update or transposition change. + + The cache restores the cbox-pattern, not the note configuration in our python data. It is + used for placing the same pattern into multiple measures. But _not_ to return to a note + configuration we already had once. If you change the pattern itself the cache is cleared. + For that we keep the _exportCacheVersion around. + Actually we do not clear the cache, we simply let it sit. + #TODO: since exportCacheVersion is basically a very basic hash of the grid status we could use it to also cache buildExportCache. Profile on a slow system first! + + Relevant for caching: + scaleTransposition + halftoneTransposition + howManyUnits + whatTypeOfUnit + subdivisions + scale (as tuple so it is hashable) + self._exportCacheVersion + + _cachedTransposedScale is updated with self.scale changes and therefore already covered. + """ + cacheHash = (scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions, tuple(self.scale), self._exportCacheVersion) + try: + return self._builtPatternCache[cacheHash] + except KeyError: + pass + + oneMeasureInTicks = howManyUnits * whatTypeOfUnit + oneMeasureInTicks /= subdivisions #subdivisions is 1 by default. bigger values mean shorter values, which is compensated by the user setting bigger howManyUnits manually. + oneMeasureInTicks = int(oneMeasureInTicks) + + exportPattern = bytes() + + for noteDict in self.exportCache: + index = noteDict["index"] + startTick = index * whatTypeOfUnit + endTick = startTick + noteDict["factor"] * whatTypeOfUnit + startTick /= subdivisions + endTick /= subdivisions + startTick = int(startTick) + endTick = int(endTick) + + if endTick > oneMeasureInTicks: + endTick = oneMeasureInTicks #all note off must end at the end of the pattern + noteDict["exceedsPlayback"] = True + else: + noteDict["exceedsPlayback"] = False + + velocity = noteDict["velocity"] + pitch = self._cachedTransposedScale[noteDict["pitch"] + scaleTransposition] + halftoneTransposition + exportPattern += cbox.Pattern.serialize_event(startTick, 0x90, pitch, velocity) # note on + exportPattern += cbox.Pattern.serialize_event(endTick-1, 0x80, pitch, velocity-1) # note off #-1 ticks to create a small logical gap. Does not affect next note on. + + pattern = cbox.Document.get_song().pattern_from_blob(exportPattern, oneMeasureInTicks) + self._builtPatternCache[cacheHash] = pattern + return pattern + + #Save / Load / Export + + def serialize(self)->dict: + return { + "scale": self.scale, + "data": self.data, + "noteNames": self.noteNames, + } + + @classmethod + def instanceFromSerializedData(cls, parentTrack, serializedData): + self = cls.__new__(cls) + self._prepareBeforeInit() + self.parentTrack = parentTrack + + #Use setters to trigger side effects like createCachedTonalRange() + self.scale = serializedData["scale"] #Scale needs to be set first because on init/load data already depends on it, at least the default scale. The scale is part of the track meta callback. + self.data = serializedData["data"] #For content see docstring. + self.noteNames = serializedData["noteNames"] #This is mostly for the GUI or other kinds of representation instead midi notes + + self._processAfterInit() + return self + + #No export. Track uses pattern data directly in its own export. + diff --git a/engine/track.py b/engine/track.py index d1d7bb6..d4f9292 100644 --- a/engine/track.py +++ b/engine/track.py @@ -21,6 +21,7 @@ along with this program. If not, see . """ #Standard Library Modules +from typing import List, Set, Dict, Tuple #Third Party Modules @@ -28,33 +29,35 @@ along with this program. If not, see . import template.engine.sequencer #Our modules +from .pattern import Pattern class Track(object): #injection at the bottom of this file! - """The pattern is same as the track, even if the GUI does not represent it that way""" + """The pattern is same as the track, even if the GUI does not represent it that way - def __repr__(self) -> str: - return f"Patroneo Track: {self.name}" + Init parameters are for cloned and copied tracks, but not for loading from json. + """ - #def __init__(self, parentScore, name="", structure=None, pattern=None, scale=None, color=None, whichPatternsAreScaleTransposed=None, whichPatternsAreHalftoneTransposed=None, noteNames=None): - def __init__(self, parentScore, name="", structure=None, pattern=None, scale=None, color=None, whichPatternsAreScaleTransposed=None, whichPatternsAreHalftoneTransposed=None, noteNames=None): + def __init__(self, parentScore, + name:str="", + structure:Set[int]=None, + scale:Tuple[int]=None, + color:str=None, + whichPatternsAreScaleTransposed:Dict[int,int]=None, + whichPatternsAreHalftoneTransposed:Dict[int,int]=None, + noteNames:List[str]=None): - print (self) + self.parentScore = parentScore + self.sequencerInterface = template.engine.sequencer.SequencerInterface(parentTrack=self, name=name) #needs parentScore self.color = color if color else "#00FFFF" # "#rrggbb" in hex. no alpha. a convenience slot for the GUI to save a color. - - #User data: - self.pattern = pattern if pattern else Pattern(parentTrack = self, scale=scale, noteNames=noteNames) + self.pattern = Pattern(parentTrack=self, scale=scale, noteNames=noteNames) self.structure = structure if structure else set() #see buildTrack(). This is the main track data structure besides the pattern. Just integers (starts at 0) as switches which are positions where to play the patterns. In between are automatic rests. + self.whichPatternsAreScaleTransposed = whichPatternsAreScaleTransposed if whichPatternsAreScaleTransposed else {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!! + self.whichPatternsAreHalftoneTransposed = whichPatternsAreHalftoneTransposed if whichPatternsAreHalftoneTransposed else {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!! + self._processAfterInit() - if whichPatternsAreScaleTransposed: - self.whichPatternsAreScaleTransposed = {int(k):int(v) for k,v in whichPatternsAreScaleTransposed.items()} #json saves dict keys as strings - else: - self.whichPatternsAreScaleTransposed = {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!! - - if whichPatternsAreHalftoneTransposed: - self.whichPatternsAreHalftoneTransposed = {int(k):int(v) for k,v in whichPatternsAreHalftoneTransposed.items()} #json saves dict keys as strings - else: - self.whichPatternsAreHalftoneTransposed = {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!! + def _processAfterInit(self): + pass def buildTrack(self): """The goal is to create a cbox-track, consisting of cbox-clips which hold cbox-pattern, @@ -68,7 +71,7 @@ class Track(object): #injection at the bottom of this file! oneMeasureInTicks = int(oneMeasureInTicks) filteredStructure = [index for index in sorted(self.structure) if index < self.parentScore.numberOfMeasures] #not <= because we compare count with range - cboxclips = [o.clip for o in self.calfboxTrack.status().clips] + cboxclips = [o.clip for o in self.sequencerInterface.calfboxTrack.status().clips] for cboxclip in cboxclips: cboxclip.delete() #removes itself from the track @@ -76,42 +79,45 @@ class Track(object): #injection at the bottom of this file! scaleTransposition = self.whichPatternsAreScaleTransposed[index] if index in self.whichPatternsAreScaleTransposed else 0 halftoneTransposition = self.whichPatternsAreHalftoneTransposed[index] if index in self.whichPatternsAreHalftoneTransposed else 0 cboxPattern = self.pattern.buildPattern(scaleTransposition, halftoneTransposition, self.parentScore.howManyUnits, self.parentScore.whatTypeOfUnit, self.parentScore.subdivisions) - r = self.calfboxTrack.add_clip(index*oneMeasureInTicks, 0, oneMeasureInTicks, cboxPattern) #pos, pattern-internal offset, length, pattern. + r = self.sequencerInterface.calfboxTrack.add_clip(index*oneMeasureInTicks, 0, oneMeasureInTicks, cboxPattern) #pos, pattern-internal offset, length, pattern. ######Old optimisations. Keep for later#### ########################################## #if changeClipsInPlace: #no need for track.buildTrack. Very cheap pattern exchange. - # cboxclips = [o.clip for o in self.parentTrack.calfboxTrack.status().clips] + # cboxclips = [o.clip for o in self.parentTrack.sequencerInterface.calfboxTrack.status().clips] # for cboxclip in cboxclips: # cboxclip.set_pattern(self.cboxPattern[cboxclip.patroneoScaleTransposed]) + #Save / Load / Export + def serialize(self)->dict: - dictionary = super().serialize() - dictionary.update( { #update in place + return { + "sequencerInterface" : self.sequencerInterface.serialize(), "color" : self.color, "structure" : list(self.structure), - "data" : self.pattern.data, - "scale" : self.pattern.scale, #The scale is part of the track meta callback. - "noteNames" : self.pattern.noteNames, #The noteNames are part of the track meta callback. + "pattern" : self.pattern.serialize(), "whichPatternsAreScaleTransposed" : self.whichPatternsAreScaleTransposed, "whichPatternsAreHalftoneTransposed" : self.whichPatternsAreHalftoneTransposed, - }) - return dictionary + } @classmethod - def instanceFromSerializedData(cls, parentTrack, serializedData): + def instanceFromSerializedData(cls, parentScore, serializedData): self = cls.__new__(cls) - self._name = serializedData["name"] - self.parentTrack = parentTrack - self.parentScore = parentTrack.parentScore - + self.parentScore = parentScore + self.sequencerInterface = template.engine.sequencer.SequencerInterface.instanceFromSerializedData(self, serializedData["sequencerInterface"]) + self.color = serializedData["color"] + self.structure = set(serializedData["structure"]) + self.whichPatternsAreHalftoneTransposed = {int(k):int(v) for k,v in serializedData["whichPatternsAreHalftoneTransposed"].items()} #json saves dict keys as strings + self.whichPatternsAreScaleTransposed = {int(k):int(v) for k,v in serializedData["whichPatternsAreScaleTransposed"].items()} #json saves dict keys as strings + self.pattern = Pattern.instanceFromSerializedData(parentTrack=self, serializedData=serializedData["pattern"] ) self._processAfterInit() return self def export(self)->dict: - dictionary = super().export() - dictionary.update({ + return { + "id" : id(self), + "sequencerInterface" : self.sequencerInterface.export(), "color" : self.color, "structure" : sorted(self.structure), "pattern": self.pattern.exportCache, @@ -120,8 +126,7 @@ class Track(object): #injection at the bottom of this file! "numberOfMeasures": self.parentScore.numberOfMeasures, "whichPatternsAreScaleTransposed": self.whichPatternsAreScaleTransposed, "whichPatternsAreHalftoneTransposed": self.whichPatternsAreHalftoneTransposed, - - }) + } #Dependency Injections. template.engine.sequencer.Score.TrackClass = Track #Score will look for Track in its module. diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index e10f8a8..ae8c794 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -23,8 +23,10 @@ import logging; logging.info("import {}".format(__file__)) #Standard Library Modules import os.path + #Third Party Modules from PyQt5 import QtWidgets, QtCore, QtGui + #Template Modules from template.qtgui.mainwindow import MainWindow as TemplateMainWindow from template.qtgui.menu import Menu @@ -126,15 +128,317 @@ class MainWindow(TemplateMainWindow): self.patternGrid = PatternGrid(parentView=self.ui.gridView) self.ui.gridView.setScene(self.patternGrid) + #Toolbar, which needs the widgets above already established + self._populateToolbar() + + #MainWindow Callbacks + api.callbacks.numberOfTracksChanged.append(self.callback_numberOfTracksChanged) + self.currentTrackId = None #this is purely a GUI construct. the engine does not know a current track. On startup there is no active track + self.start() #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange. #There is always a track. Forcing that to be active is better than having to hide all the pattern widgets, or to disable them. + #However, we need the engine to be ready. self.chooseCurrentTrack(api.session.data.tracks[0].export()) #By Grabthar's hammer, by the suns of Worvan, what a hack! #TODO: Access to the sessions data structure directly instead of api. Not good. Getter function or api @property is cleaner. - self.start() #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange. + def callback_numberOfTracksChanged(self, exportDictList): + """We need to find out of the current track was the deleted one or if a new track got added + automatically.""" + #if self.programStarted and len(exportDictList) == 1: + if len(exportDictList) == 1: + self.chooseCurrentTrack(exportDictList[0]) + + def chooseCurrentTrack(self, exportDict): + """This is in mainWindow because we need access to different sections of the program. + newCurrentTrack is a backend track ID + + This is not triggered by the engine but by our GUI functions. exportDict is not the current + engine data but a cached version. Careful! For example the pattern redraws when the + current track changes, but there was no callback that informed the songeditor of a changed + pattern because it doesn't deal with patterns. So it didn't receive the new exportDict and + sent its old cached version to the patternGrid via this function. So the grid never + got the new information and "forgot" all settings. + """ + newCurrentTrackId = exportDict["id"] + + if self.currentTrackId == newCurrentTrackId: + return True + + participantsDicts = (self.trackLabelEditor.tracks, self.songEditor.tracks) #all structures with dicts with key trackId that need active/inactive marking + for d in participantsDicts: + try: #First mark old one inactive + d[self.currentTrackId].mark(False) + except KeyError: #track was deleted or never existed (load empty file) + pass + d[newCurrentTrackId].mark(True) #New one as active + + self.patternGrid.guicallback_chooseCurrentTrack(exportDict) + + #Remember current one for next round and for other functions + #Functions depend on getting set after getting called. They need to know the old track! + self.currentTrackId = newCurrentTrackId def addTrack(self): """Add a new track and initialize it with some data from the current one""" scale = api.session.data.trackById(self.currentTrackId).pattern.scale #TODO: Access to the sessions data structure directly instead of api. Not good. Getter function or api @property is cleaner. api.addTrack(scale) + + def cloneSelectedTrack(self): + """Add a new track via the template option. This is the best track add function""" + newTrackExporDict = api.createSiblingTrack(self.currentTrackId) + self.chooseCurrentTrack(newTrackExporDict) + + def _populateToolbar(self): + self.ui.toolBar.contextMenuEvent = api.nothing #remove that annoying Checkbox context menu + + #Designer Buttons + self.ui.actionAddTrack.triggered.connect(self.addTrack) + self.ui.actionClone_Selected_Track.triggered.connect(self.cloneSelectedTrack) + + #New Widgets + + #BPM. Always in quarter notes to keep it simple + beatsPerMinuteBlock = QtWidgets.QWidget() + bpmLayout = QtWidgets.QHBoxLayout() + bpmLayout.setSpacing(0) + bpmLayout.setContentsMargins(0,0,0,0) + beatsPerMinuteBlock.setLayout(bpmLayout) + + bpmCheckbox = QtWidgets.QCheckBox(QtCore.QCoreApplication.translate("Toolbar", "BPM/Tempo: ")) + bpmCheckbox.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Deactivate to beccome JACK Transport Slave. Activate for Master.")) + bpmLayout.addWidget(bpmCheckbox) + + + beatsPerMinute = QtWidgets.QSpinBox() + beatsPerMinute.setToolTip(bpmCheckbox.toolTip()) + beatsPerMinute.setMinimum(0) #0 means off + beatsPerMinute.setMaximum(999) + beatsPerMinute.setSpecialValueText("JACK") + bpmLayout.addWidget(beatsPerMinute) + + def quarterNotesPerMinuteChanged(): + beatsPerMinute.blockSignals(True) + assert bpmCheckbox.isChecked() + value = beatsPerMinute.value() + api.set_quarterNotesPerMinute(max(1, value)) + beatsPerMinute.blockSignals(False) + beatsPerMinute.editingFinished.connect(quarterNotesPerMinuteChanged) + + def bpmCheckboxChanged(state): + bpmCheckbox.blockSignals(True) + if state: + api.set_quarterNotesPerMinute("on") + else: + api.set_quarterNotesPerMinute(None) + bpmCheckbox.blockSignals(False) + + bpmCheckbox.stateChanged.connect(bpmCheckboxChanged) + + def callback_quarterNotesPerMinuteChanged(newValue): + #We just receive an int, not a dict. + beatsPerMinute.blockSignals(True) + bpmCheckbox.blockSignals(True) + + if newValue: + bpmCheckbox.setChecked(True) + beatsPerMinute.setEnabled(True) + beatsPerMinute.setValue(newValue) + else: + beatsPerMinute.setEnabled(False) + bpmCheckbox.setChecked(False) + beatsPerMinute.setValue(0) + + beatsPerMinute.blockSignals(False) + bpmCheckbox.blockSignals(False) + api.callbacks.quarterNotesPerMinuteChanged.append(callback_quarterNotesPerMinuteChanged) + + + + #Number of Measures + numberOfMeasures = QtWidgets.QSpinBox() + numberOfMeasures.setMinimum(1) + numberOfMeasures.setMaximum(9999) + numberOfMeasures.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Overall length of the song")) + def numberOfMeasuresChanged(): + numberOfMeasures.blockSignals(True) + api.set_numberOfMeasures(int(numberOfMeasures.value())) + numberOfMeasures.blockSignals(False) + numberOfMeasures.editingFinished.connect(numberOfMeasuresChanged) + + def callback_setnumberOfMeasures(exportDictScore): + numberOfMeasures.blockSignals(True) + numberOfMeasures.setValue(exportDictScore["numberOfMeasures"]) + numberOfMeasures.blockSignals(False) + api.callbacks.numberOfMeasuresChanged.append(callback_setnumberOfMeasures) + + + #Subdivisions + #See manual + numberOfSubdivisions = QtWidgets.QSpinBox() + numberOfSubdivisions.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Please read the manual!")) + self.numberOfSubdivisions = numberOfSubdivisions #access from the convert dialog + numberOfSubdivisions.setMinimum(1) + numberOfSubdivisions.setMaximum(4) + def numberOfSubdivisionsChanged(): + api.set_subdivisions(numberOfSubdivisions.value()) + numberOfSubdivisions.editingFinished.connect(numberOfSubdivisionsChanged) + def callback_subdivisionsChanged(newValue): + numberOfSubdivisions.blockSignals(True) + numberOfSubdivisions.setValue(newValue) + numberOfSubdivisions.blockSignals(False) + api.callbacks.subdivisionsChanged.append(callback_subdivisionsChanged) + + + #Time Signature + unitTypes = [ + QtCore.QCoreApplication.translate("TimeSignature", "Whole"), + QtCore.QCoreApplication.translate("TimeSignature", "Half"), + QtCore.QCoreApplication.translate("TimeSignature", "Quarter"), + QtCore.QCoreApplication.translate("TimeSignature", "Eigth"), + QtCore.QCoreApplication.translate("TimeSignature", "Sixteenth") + ] + units = [api.D1, api.D2, api.D4, api.D8, api.D16] + + howManyUnits = QtWidgets.QSpinBox() + howManyUnits.setMinimum(1) + howManyUnits.setMaximum(999) + howManyUnits.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Length of the pattern (bottom part of the program)")) + whatTypeOfUnit = QtWidgets.QComboBox() + whatTypeOfUnit.addItems(unitTypes) + #whatTypeOfUnit.setStyleSheet("QComboBox { background-color: transparent; } QComboBox QAbstractItemView { selection-background-color: transparent; } "); + whatTypeOfUnit.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "How long is each main step")) + + def typeChanged(index): + whatTypeOfUnit.blockSignals(True) + api.set_whatTypeOfUnit(units[index]) + whatTypeOfUnit.blockSignals(False) + whatTypeOfUnit.currentIndexChanged.connect(typeChanged) + + def numberChanged(): + howManyUnits.blockSignals(True) + api.set_howManyUnits(howManyUnits.value()) + howManyUnits.blockSignals(False) + howManyUnits.editingFinished.connect(numberChanged) + + def callback_setTimeSignature(nr, typ): + howManyUnits.blockSignals(True) + whatTypeOfUnit.blockSignals(True) + + idx = units.index(typ) + whatTypeOfUnit.setCurrentIndex(idx) + + howManyUnits.setValue(nr) + + howManyUnits.blockSignals(False) + whatTypeOfUnit.blockSignals(False) + api.callbacks.timeSignatureChanged.append(callback_setTimeSignature) + + + #Subdivisions Convert Button + convertSubdivisions = QtWidgets.QToolButton() + convertSubdivisions.setText(QtCore.QCoreApplication.translate("Toolbar", "Convert Grouping")) + convertSubdivisions.clicked.connect(self.convertSubdivisionsSubMenu) + convertSubdivisions.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Change step grouping but keep your music the same")) + + + #Add all to Toolbar + def spacer(): + """Insert a spacing widget. positions depends on execution order""" + spacer = QtWidgets.QWidget() + spacer.setFixedWidth(30) + self.ui.toolBar.addWidget(spacer) + + #Clone Track and AddTrack button is added through Designer but we change the text here to get a translation + self.ui.actionClone_Selected_Track.setText(QtCore.QCoreApplication.translate("Toolbar", "Clone Selected Track")) + self.ui.actionClone_Selected_Track.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Use this! Create a new track that inherits everything but the content from the original. Already jack connected!")) + self.ui.actionAddTrack.setText(QtCore.QCoreApplication.translate("Toolbar", "Add Track")) + self.ui.actionAddTrack.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Add a complete empty track that needs to be connected to an instrument manually.")) + spacer() + self.ui.toolBar.addWidget(beatsPerMinuteBlock) # combined widget with its label and translation included + spacer() + + l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", "Measures per Track: ")) + l.setToolTip(numberOfMeasures.toolTip()) + self.ui.toolBar.addWidget(l) + self.ui.toolBar.addWidget(numberOfMeasures) + spacer() + + l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", "Steps per Pattern:")) + l.setToolTip(howManyUnits.toolTip()) + self.ui.toolBar.addWidget(l) + self.ui.toolBar.addWidget(howManyUnits) + + l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", " in groups of: ")) + l.setToolTip(howManyUnits.toolTip()) + self.ui.toolBar.addWidget(l) + self.ui.toolBar.addWidget(numberOfSubdivisions) + + l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", " so that each group produces a:")) + l.setToolTip(whatTypeOfUnit.toolTip()) + self.ui.toolBar.addWidget(l) + self.ui.toolBar.addWidget(whatTypeOfUnit) + spacer() + + self.ui.toolBar.addWidget(convertSubdivisions) + + def zoomUpperHalf(self, delta): + """This is called from within the parts of the combined song editor. + The Song Editor consists of three graphic scenes and their views. + But only the part where you can switch measures on and off calls this.""" + + try: self.zoomFactor + except: self.zoomFactor = 1 # no save. We don't keep a qt config. + + if delta > 0: #zoom in + self.zoomFactor = min(5, round(self.zoomFactor + 0.25, 2)) + else: #zoom out + self.zoomFactor = max(1, round(self.zoomFactor - 0.25, 2)) + + for view in (self.ui.songEditorView, self.ui.trackEditorView, self.ui.timelineView): + view.resetTransform() + + self.ui.songEditorView.scale(self.zoomFactor, self.zoomFactor) + self.ui.trackEditorView.scale(1, self.zoomFactor) + self.ui.timelineView.scale(self.zoomFactor, 1) + #view.centerOn(event.scenePos()) + + def convertSubdivisionsSubMenu(self): + class Submenu(QtWidgets.QDialog): + def __init__(self, mainWindow): + super().__init__(mainWindow) #if you don't set the parent to the main window the whole screen will be the root and the dialog pops up in the middle of it. + #self.setModal(True) #we don't need this when called with self.exec() instead of self.show() + self.layout = QtWidgets.QFormLayout() + self.setLayout(self.layout) + self.numberOfSubdivisions = QtWidgets.QSpinBox() + self.numberOfSubdivisions.setMinimum(1) + self.numberOfSubdivisions.setMaximum(4)# + self.numberOfSubdivisions.setValue(mainWindow.numberOfSubdivisions.value()) + + self.layout.addRow(QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "New Grouping"), self.numberOfSubdivisions) + + self.errorHandling = QtWidgets.QComboBox() + self.errorHandling.addItems([ + QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "Do nothing"), + QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "Delete wrong steps"), + QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "Merge wrong steps"), + ]) + self.layout.addRow(QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "If not possible"), self.errorHandling) + + #self.setFocus(); #self.grabKeyboard(); #redundant for a proper modal dialog. Leave here for documentation reasons. + + buttonBox = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + buttonBox.accepted.connect(self.accept) + buttonBox.rejected.connect(self.reject) + self.layout.addWidget(buttonBox) + + def __call__(self): + """This instance can be called like a function""" + self.exec() #blocks until the dialog gets closed + + s = Submenu(self) + s() + if s.result(): + value = s.numberOfSubdivisions.value() + error= ("fail", "delete", "merge")[s.errorHandling.currentIndex()] + api.convert_subdivisions(value, error) diff --git a/qtgui/pattern_grid.py b/qtgui/pattern_grid.py index 3db7689..cbab922 100644 --- a/qtgui/pattern_grid.py +++ b/qtgui/pattern_grid.py @@ -136,8 +136,6 @@ class PatternGrid(QtWidgets.QGraphicsScene): """ assert not exportDict["id"] == self.parentView.parentMainWindow.currentTrackId #this is still the old track. - #self.trackName.setText(exportDict["name"]) - updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes. self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate) @@ -195,7 +193,7 @@ class PatternGrid(QtWidgets.QGraphicsScene): """ if force or self.parentView.parentMainWindow.currentTrackId == exportDict["id"]: - self.trackName.setText(exportDict["name"]) + self.trackName.setText(exportDict["sequencerInterface"]["name"]) self.trackName.show() c = QtGui.QColor(exportDict["color"]) self.currentColor = c diff --git a/qtgui/songeditor.py b/qtgui/songeditor.py index 4707dd2..f982d87 100644 --- a/qtgui/songeditor.py +++ b/qtgui/songeditor.py @@ -116,7 +116,7 @@ class SongEditor(QtWidgets.QGraphicsScene): self.removeItem(trackStructure) #remove from scene del trackStructure - assert all(track.exportDict["index"] == self.trackOrder.index(track) for track in self.tracks.values()) + assert all(track.exportDict["sequencerInterface"]["index"] == self.trackOrder.index(track) for track in self.tracks.values()) self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT self.setSceneRect(0,0,exportDict["numberOfMeasures"]*SIZE_UNIT,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET) #no empty space on top without a scene rect. Also a bit of leniance. self.playhead.setLine(0, 0, 0, self.cachedCombinedTrackHeight) #(x1, y1, x2, y2) @@ -571,7 +571,7 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene): del item #yes, manual memory management in Python. We need to get rid of anything we want to delete later or Qt will crash because we have a python wrapper without a qt object listOfLabelsAndFunctions = [ - (exportDict["name"], None), + (exportDict["sequencerInterface"]["name"], None), (QtCore.QCoreApplication.translate("TrackLabelContext", "Invert Measures"), lambda: api.trackInvertSwitches(exportDict["id"])), (QtCore.QCoreApplication.translate("TrackLabelContext", "All Measures On"), lambda: api.trackOnAllSwitches(exportDict["id"])), (QtCore.QCoreApplication.translate("TrackLabelContext", "All Measures Off"), lambda: api.trackOffAllSwitches(exportDict["id"])), @@ -599,7 +599,7 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene): for track in self.tracks.values(): sourceDict = track.exportDict - a = QtWidgets.QAction(sourceDict["name"], mergeMenu) + a = QtWidgets.QAction(sourceDict["sequencerInterface"]["name"], mergeMenu) mergeMenu.addAction(a) mergeCommand = createCopyMergeLambda(sourceDict["id"]) if sourceDict["id"] == exportDict["id"]: @@ -734,7 +734,7 @@ class TrackLabel(QtWidgets.QGraphicsRectItem): def sendToEngine(self): self.setReadOnly(True) new = self.text() - if not new == self.parentTrackLabel.exportDict["name"]: + if not new == self.parentTrackLabel.exportDict["sequencerInterface"]["name"]: self.blockSignals(True) api.changeTrackName(self.parentTrackLabel.exportDict["id"], new) self.blockSignals(False) @@ -749,7 +749,7 @@ class TrackLabel(QtWidgets.QGraphicsRectItem): def update(self, exportDict): - self.lineEdit.setText(exportDict["name"]) + self.lineEdit.setText(exportDict["sequencerInterface"]["name"]) self.exportDict = exportDict self.colorButton.setBrush(QtGui.QColor(exportDict["color"]))