From e25d0d1c17b5ad7d65ed2949d09ff4828494d49b Mon Sep 17 00:00:00 2001 From: Nils <> Date: Sun, 10 Jan 2021 20:27:38 +0100 Subject: [PATCH] First steps to allow more notes per pattern than 8 --- engine/api.py | 73 ++++++++++++++++++++++++++++++++----------- engine/pattern.py | 36 ++++++++++++--------- engine/track.py | 1 + qtgui/mainwindow.py | 4 ++- qtgui/pattern_grid.py | 56 +++++++++++++++++++++++++-------- 5 files changed, 124 insertions(+), 46 deletions(-) diff --git a/engine/api.py b/engine/api.py index 9d50c78..9e527df 100644 --- a/engine/api.py +++ b/engine/api.py @@ -14,12 +14,11 @@ import template.engine.api #we need direct access to the module to inject data i from template.engine.api import * from template.engine.duration import baseDurationToTraditionalNumber -#Our modules -from .pattern import NUMBER_OF_STEPS +#Our own engine Modules +pass DEFAULT_FACTOR = 1 #for the GUI. - #New callbacks class ClientCallbacks(Callbacks): #inherits from the templates api callbacks def __init__(self): @@ -762,24 +761,27 @@ def patternRowChangeVelocity(trackId, pitchindex, delta): callbacks._patternChanged(track) -major = [0, 2, 4, 5, 7, 9, 11, 12] #this if sorted by pitch, lowest to highest. Patroneo works in reverse order to accomodate the row/column approach of a grid. We reverse in setScaleToKeyword +major = [0, 2, 4, 5, 7, 9, 11] #this is sorted by pitch, lowest to highest. Patroneo works in reverse order to accomodate the row/column approach of a grid. We reverse in setScaleToKeyword schemesDict = { #this if sorted by pitch, lowest to highest. Patroneo works in reverse order to accomodate the row/column approach of a grid. We reverse in setScaleToKeyword - "Major": [0,0,0,0,0,0,0,0], - "Minor": [0,0,-1,0,0,-1,-1,0], - "Dorian": [0,0,-1,0,0,0,-1,0], - "Phrygian": [0,-1,-1,0,0,-1,-1,0], - "Lydian": [0,0,0,+1,0,0,0,0], - "Mixolydian": [0,0,0,0,0,0,-1,0], - "Locrian": [0,-1,-1,0,-1,-1,-1,0], - "Blues": [0,-2,-1,0,-1,-2,-1,0], - "Hollywood": [0,0,0,0,0,-1,-1,0], #The "Hollywood"-Scale. Stargate, Lord of the Rings etc. - "Chromatic": [0,-1,-2,-2,-3,-4,-5,-5], #not a complete octave, but that is how it goes. + #The lowest/first pitch is always 0 because it is just the given root note. + "Major": [0,0,0,0,0,0,0], + "Minor": [0,0,-1,0,0,-1,-1], + "Dorian": [0,0,-1,0,0,0,-1], + "Phrygian": [0,-1,-1,0,0,-1,-1], + "Lydian": [0,0,0,+1,0,0,0], + "Mixolydian": [0,0,0,0,0,0,-1], + "Locrian": [0,-1,-1,0,-1,-1,-1], + "Blues": [0,-2,-1,0,-1,-2,-1], #blues is a special case. It has less notes than we offer. Set a full scale and another script will mute/hide those extra steps. + "Blues": [0, +1, +1, +1, 0, +1], + "Hollywood": [0,0,0,0,0,-1,-1], #The "Hollywood"-Scale. Stargate, Lord of the Rings etc. + "Chromatic": [0,-1,-2,-2,-3,-4,-5,-5,-6, -7, -7, -8, -9, -10, -10], #crude... } major.reverse() for l in schemesDict.values(): l.reverse() +#Ordered version schemes = [ "Major", "Minor", @@ -794,12 +796,47 @@ schemes = [ ] def setScaleToKeyword(trackId, keyword): + """Use a builtin base scale and apply to all notes in a pattern. If there are more more ore + fewer notes in the pattern than in the scale we will calculate the rest. + + This function is called not often and does not need to be performant. + """ track = session.data.trackById(trackId) - rememberRootNote = track.pattern.scale[-1] #no matter if this is the lowest or not% - scale = [x + y for x, y in zip(major, schemesDict[keyword])] - difference = rememberRootNote - scale[-1] - result = [midipitch+difference for midipitch in scale] + rememberRootNote = track.pattern.scale[-1] #The last note has a special role by convention. No matter if this is the lowest midi-pitch or not. Most of the time it is the lowest though. + + #Create a modified scalePattern for the tracks numberOfSteps. + #We technically only need to worry about creating additional steps. less steps is covered by zip(), see below + majorExt = [] + schemeExt = [] + mrev = list(reversed(major*16)) #pad major to the maximum possible notes. We just need the basis to make it possible long schemes like chromatic fit + srev = list(reversed(schemesDict[keyword]*16)) + for i in range(track.pattern.numberOfSteps): + l = len(srev) + octaveOffset = i // l * 12 #starts with 0*12 + majorExt.append( mrev[i % l ] + octaveOffset) + schemeExt.append( srev[i % l] ) #this is always the same. it is only the difference to the major scale + + majorExt = list(reversed(majorExt)) + schemeExt = list(reversed(schemeExt)) + + scale = [x + y for x, y in zip(majorExt, schemeExt)] #zip just creates pairs until it reached the end of one of its arguments. This is reversed order. + #scale = [x + y for x, y in zip(major, schemesDict[keyword])] #zip just creates pairs until it reached the end of one of its arguments. This is reversed order. + difference = rememberRootNote - scale[-1] #isn't this the same as rootnote since scale[-1] is always 0? Well, we could have hypo-scales in the future. + result = [midipitch+difference for midipitch in scale] #create actual midi pitches from the root note and the scale. This is reversed order because "scale" is. + + #Here is a hack because chromatic didn't work with octave wrap-around. We want to make sure we don't fall back to a lower octave + r = reversed(result) + result = [] + oldPitch = 0 + for p in r: + while p <= oldPitch: + p += 12 + result.append(p) + oldPitch = p + result = reversed(result) + + #Done. Inform all parties. track.pattern.scale = result track.pattern.buildExportCache() track.buildTrack() diff --git a/engine/pattern.py b/engine/pattern.py index c466983..f654461 100644 --- a/engine/pattern.py +++ b/engine/pattern.py @@ -29,23 +29,24 @@ from statistics import median #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 + In fact a pattern IS a track. + Having it as its own class is only for code readability A pattern is an unordered list of dicts. Each dict is an step, or a note. - {"index": from 0 to parentTrack.parentData.howManyUnits, + Only existing steps (Switched On) are in self.data + {"index": from 0 to parentTrack.parentData.howManyUnits * stretchfactor. But can be higher, will just not be played or exported., "factor": float, "pitch": int 0-7, "velocity":int 0-127, } - The pitch is determined by an external scale, which is a list of len - 7 of midi pitches. Our "pitch" is an index in this list. + The pitch is determined by an external scale, which is a list 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. @@ -53,20 +54,22 @@ class Pattern(object): note. The pattern has always its maximum length. - """ + def __init__(self, parentTrack, data:List[dict]=None, scale:Tuple[int]=None, simpleNoteNames:List[str]=None): self._prepareBeforeInit() self.parentTrack = parentTrack + #self.scale = scale if scale else (72+ 12, 71+12, 69+12, 67+12, 65+12, 64+12, 62+12, 60+12, 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.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.simpleNoteNames = simpleNoteNames if simpleNoteNames else self.parentTrack.parentData.lastUsedNotenames[:] #This is mostly for the GUI or other kinds of representation instead midi notes assert self.simpleNoteNames - - #2.0 Pitch + #Extended Pitch #Explanation: Why don't we just do 12 steps per ocatve and then leave steps blank to create a scale? - #We still want the: User set steps, not pitch idiom. + #We still want the "User set steps, not pitch" idiom. + + #self.mutedSteps = set() #self.stepsPerOctave = 7 # This is not supposed to go below 7! e.g. Pentatonic scales are done by leaving steps out with self.blankSteps. #self.nrOfSteps = 8 # Needs to be >= stepsPerOctave. stepsPerOctave+1 will, by default, result in a full scale plus its octave. That can of course later be changed. @@ -74,12 +77,10 @@ class Pattern(object): 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. @@ -103,7 +104,10 @@ class Pattern(object): @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""" + For that reason we keep scale an immutable tuple instead of a list. + + The number of steps are determined by the scale length + """ self._scale = tuple(value) self.createCachedTonalRange() @@ -116,6 +120,11 @@ class Pattern(object): self._simpleNoteNames = tuple(value) #we keep it immutable, this is safer to avoid accidental linked structures when creating a clone. self.parentTrack.parentData.lastUsedNotenames = self._simpleNoteNames #new default for new tracks + + @property + def numberOfSteps(self): + return len(self.scale) + def fill(self): """Create a 2 dimensional array""" l = len(self.scale) @@ -238,7 +247,7 @@ class Pattern(object): #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): + for step in range(self.numberOfSteps): 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. @@ -248,7 +257,6 @@ class Pattern(object): 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.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator): # < and not <= because index counts from 0 but howManyUnits counts from 1 note = {} diff --git a/engine/track.py b/engine/track.py index 9ddd907..886d8c7 100644 --- a/engine/track.py +++ b/engine/track.py @@ -139,6 +139,7 @@ class Track(object): #injection at the bottom of this file! "patternLengthMultiplicator" : self.patternLengthMultiplicator, #int "pattern": self.pattern.exportCache, "scale": self.pattern.scale, + "numberOfSteps": self.pattern.numberOfSteps, "simpleNoteNames": self.pattern.simpleNoteNames, "numberOfMeasures": self.parentData.numberOfMeasures, "whichPatternsAreScaleTransposed": self.whichPatternsAreScaleTransposed, diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index cfc8690..2a1cfee 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -119,6 +119,8 @@ class MainWindow(TemplateMainWindow): self.ui.centralwidget.addAction(self.ui.actionToStart) #no action without connection to a widget. self.ui.actionToStart.triggered.connect(self.ui.toStartButton.click) + self.currentTrackId = None #this is purely a GUI construct. the engine does not know a current track. On startup there is no active track + ##Song Editor self.ui.songEditorView.parentMainWindow = self self.songEditor = SongEditor(parentView=self.ui.songEditorView) @@ -161,7 +163,7 @@ class MainWindow(TemplateMainWindow): #Toolbar, which needs the widgets above already established self._populateToolbar() - 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. diff --git a/qtgui/pattern_grid.py b/qtgui/pattern_grid.py index 63d4272..d3d11c7 100644 --- a/qtgui/pattern_grid.py +++ b/qtgui/pattern_grid.py @@ -123,15 +123,18 @@ class PatternGrid(QtWidgets.QGraphicsScene): if forceId: factor = self._tracks[forceId]["patternLengthMultiplicator"] + numberOfSteps = self._tracks[forceId]["numberOfSteps"] else: if not self.parentView.parentMainWindow.currentTrackId or not self.parentView.parentMainWindow.currentTrackId in self._tracks: factor = 1 #program start. Purely internal. Will be overriden by a second callback before the user sees it. + numberOfSteps = 1 else: factor = self._tracks[self.parentView.parentMainWindow.currentTrackId]["patternLengthMultiplicator"] + numberOfSteps = self._tracks[self.parentView.parentMainWindow.currentTrackId]["numberOfSteps"] #Build a two dimensional grid for column in range(howMany*factor): - for row in range(api.NUMBER_OF_STEPS): + for row in range(numberOfSteps): x = column * SIZE_UNIT + SIZE_RIGHT_OFFSET y = row * SIZE_UNIT + SIZE_TOP_OFFSET @@ -200,6 +203,7 @@ class PatternGrid(QtWidgets.QGraphicsScene): velocityAndFactor = (noteDict["velocity"], noteDict["factor"]) self._steps[(x,y)].on(velocityAndFactor=velocityAndFactor, exceedsPlayback=noteDict["exceedsPlayback"]) + self.scale.buildScale(exportDict["numberOfSteps"]) self.scale.setScale(exportDict["scale"]) self.scale.setNoteNames(exportDict["simpleNoteNames"]) @@ -652,28 +656,48 @@ class Step(QtWidgets.QGraphicsRectItem): class Scale(QtWidgets.QGraphicsRectItem): + """SpinBoxes on the left side of the steps""" + + def __init__(self, parentScene): super().__init__(0,0,0,0) self.parentScene = parentScene self.pitchWidgets = [] #sorted from top to bottom in Step Rect and scene coordinates self.simpleNoteNames = None #list of 128 notes. use index with note name. Can be changed at runtime. Never empty. api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged) - self.buildScale() #also sets the positions of the buttons above + #self.buildScale(1) #also sets the positions of the buttons above def callback_trackMetaDataChanged(self, exportDict): #Order matters. We need to set the notenames before the scale. + self.buildScale(exportDict["numberOfSteps"]) self.setNoteNames(exportDict["simpleNoteNames"]) self.setScale(exportDict["scale"]) - def buildScale(self): - """Only executed once per pattern""" - for i in range(api.NUMBER_OF_STEPS): - p = PitchWidget(parentItem=self) - y = i * SIZE_UNIT - p.setParentItem(self) - p.setPos(-65, y+10) - self.pitchWidgets.append(p) - #self.setRect(0,0, SIZE_RIGHT_OFFSET, p.y() + SIZE_UNIT) #p is the last of the 8. + + def buildScale(self, numberOfSteps): + """This is used for building the GUI as well as switching the active track. + We only add, show and hide. Never delete steps.""" + + stepsSoFar = len(self.pitchWidgets) + + if numberOfSteps < stepsSoFar: #reduce only by hiding pitchWidgets + for i, pitchWidget in enumerate(self.pitchWidgets): + if i < numberOfSteps: + pitchWidget.show() + else: + pitchWidget.hide() + + else: #create new steps, incrementally + for i in range(numberOfSteps): + if i < stepsSoFar: #already exists + self.pitchWidgets[i].show() + else: + p = PitchWidget(parentItem=self) + y = i * SIZE_UNIT + p.setParentItem(self) + p.setPos(-65, y+10) + self.pitchWidgets.append(p) + #self.setRect(0,0, SIZE_RIGHT_OFFSET, p.y() + SIZE_UNIT) #p is the last of the 8. def setScale(self, scaleList): """We receive from top to bottom, in step rect coordinates. This is not sorted after @@ -701,7 +725,9 @@ class Scale(QtWidgets.QGraphicsRectItem): api.setScale(trackId, scale=result, callback=callback) class TransposeControls(QtWidgets.QWidget): - """Communication with the scale spinBoxes is done via api callbacks. We just fire and forget""" + """ + The row of widgets between the track structures and the patterns step grid + Communication with the scale spinBoxes is done via api callbacks. We just fire and forget""" #Not working. the translation generate works statically. translatedScales = [QtCore.QT_TRANSLATE_NOOP("Scale", scale) for scale in api.schemes] #No choice but to prepare the translations manually here. At least we do not need to watch for the order. @@ -794,6 +820,7 @@ class TransposeControls(QtWidgets.QWidget): api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-12) def transposeToScale(self, index): + """Index is a shared index, by convention, between our drop-down list and api.schemes""" if index > 0: index -= 1 # the backend list obviously has no "Set Scale to" on index [0] api.setScaleToKeyword(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, keyword=api.schemes[index]) #this schemes must NOT be translated since it is the original key/symbol. @@ -886,7 +913,7 @@ class PitchWidget(QtWidgets.QGraphicsProxyWidget): class Playhead(QtWidgets.QGraphicsLineItem): def __init__(self, parentScene): - super().__init__(0, 0, 0, api.NUMBER_OF_STEPS*SIZE_UNIT) # (x1, y1, x2, y2) + super().__init__(0, 0, 0, SIZE_UNIT) # (x1, y1, x2, y2) #Program start we are just 1 unit high. Changes with actual data. self.parentScene = parentScene p = QtGui.QPen() p.setColor(QtGui.QColor("red")) @@ -903,6 +930,9 @@ class Playhead(QtWidgets.QGraphicsLineItem): x = (tickindex % (self.parentScene.oneMeasureInTicks * factor)) / self.parentScene.ticksToPixelRatio x += SIZE_RIGHT_OFFSET if playbackStatus: # api.duringPlayback: + numberOfSteps = self.parentScene._tracks[self.parentScene.parentView.parentMainWindow.currentTrackId]["numberOfSteps"] + self.setLine(0, 0, 0, numberOfSteps * SIZE_UNIT) + self.show() self.setX(x) scenePos = self.parentScene.parentView.mapFromScene(self.pos())