diff --git a/engine/api.py b/engine/api.py index 9e527df..28a258e 100644 --- a/engine/api.py +++ b/engine/api.py @@ -772,10 +772,10 @@ schemesDict = { "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], + #"Blues": [0,-2,-1,0,-1,-2,-1], #blues is a special case. It has less notes than we offer. + "Blues": [0, +1, +1, +1, 0, +2, +1], #broden. Needs double octave in the middle. better than completely wrong. "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... + "Chromatic": [0,-1,-2,-2,-3,-4,-5,-5,-6, -7, -7, -8, -9, -10, -10], #crude... also broken > 2 octaves } major.reverse() for l in schemesDict.values(): @@ -830,7 +830,7 @@ def setScaleToKeyword(trackId, keyword): result = [] oldPitch = 0 for p in r: - while p <= oldPitch: + while p < oldPitch: p += 12 result.append(p) oldPitch = p @@ -854,6 +854,44 @@ def changePatternVelocity(trackId, steps): updatePlayback() callbacks._patternChanged(track) +def resizePatternWithoutScale(trackId, steps): + """Resize a patterns number of steps without changing the scale. + Can't go below 1 step. + + Our editing end is the bottom one, where new steps are removed or added. + + We also cannot know if there the user set a scale through an api scheme. At the very least + this needs analyzing and taking an educated guess. For now we just add notes a semitone below. + """ + if steps < 1 or steps > 128: + logger.warning(f"Pattern must have >= 1 and <= 127 steps but {steps} was requested. Doing nothing.") + return + + track = session.data.trackById(trackId) + currentNr = track.pattern.numberOfSteps + oldid = id(track.pattern.scale) + s = track.pattern.scale #GUI view: from top to bottom. Usually from higher pitches to lower. (49, 53, 50, 45, 42, 39, 38, 36) + if steps == currentNr: + return + + if steps < currentNr: #just reduce + track.pattern.scale = tuple(s[:steps]) + else: #new + currentLowest = s[-1] #int + result = list(s) #can be edited. + for i in range(steps-currentNr): + currentLowest -= 1 + result.append(currentLowest) + track.pattern.scale = tuple(result) + assert track.pattern.numberOfSteps == steps, (track.pattern.numberOfSteps, steps) + + assert not oldid == id(track.pattern.scale) + + track.pattern.buildExportCache() + track.buildTrack() + updatePlayback() + callbacks._patternChanged(track) + #Other functions. These can't be template functions because they use a specific track and Patroneos row and scale system. def noteOn(trackId, row): diff --git a/engine/main.py b/engine/main.py index d717605..8b6f30d 100644 --- a/engine/main.py +++ b/engine/main.py @@ -82,7 +82,7 @@ class Data(template.engine.sequencer.Score): 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.""" + proportionally. But only if possible.""" assert errorHandling in ("fail", "delete", "merge") @@ -92,7 +92,7 @@ class Data(template.engine.sequencer.Score): #the easiest case. New value is bigger and a multiple of the old one. 1->everything, 2->4. #We do need not check if the old notes have a place in the new grid because there are more new places than before if int(scaleFactor) == scaleFactor: - assert int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits + assert int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits, (scaleFactor, self.howManyUnits) self.howManyUnits = int(scaleFactor * self.howManyUnits) for track in self.tracks: for step in track.pattern.data: @@ -101,7 +101,7 @@ class Data(template.engine.sequencer.Score): #Possible case, but needs checking. elif int(inverseScaleFactor) == inverseScaleFactor: - assert int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits + assert int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits, (scaleFactor, self.howManyUnits) #Test, if in "fail" mode if errorHandling == "fail": diff --git a/engine/pattern.py b/engine/pattern.py index f654461..2ca9503 100644 --- a/engine/pattern.py +++ b/engine/pattern.py @@ -59,22 +59,11 @@ class Pattern(object): 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 - #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. - - #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. - #self.blankSteps = [] # Finally, some of the steps, relative to the octave, will be left blank and mute. This should create a visible gap in the GUI. Use this for pentatonic. - self._processAfterInit() def _prepareBeforeInit(self): @@ -120,11 +109,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) @@ -258,7 +247,10 @@ class Pattern(object): """Called by the api directly and once on init/load""" self.exportCache = [] #only used by parentTrack.export() - 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 + patternOnlyCurrentHowManyUnits = (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 + patternOnlyCurrentNumberOfSteps = (p for p in patternOnlyCurrentHowManyUnits if p["pitch"] < self.numberOfSteps) + + for pattern in patternOnlyCurrentNumberOfSteps: note = {} note["pitch"] = pattern["pitch"] note["index"] = pattern["index"] diff --git a/engine/track.py b/engine/track.py index 886d8c7..e327a30 100644 --- a/engine/track.py +++ b/engine/track.py @@ -139,7 +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, + "numberOfSteps": self.pattern.numberOfSteps, #pitches. Convenience for len(scale) "simpleNoteNames": self.pattern.simpleNoteNames, "numberOfMeasures": self.parentData.numberOfMeasures, "whichPatternsAreScaleTransposed": self.whichPatternsAreScaleTransposed, diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index 2a1cfee..f46f0c5 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -209,6 +209,7 @@ class MainWindow(TemplateMainWindow): d[newCurrentTrackId].mark(True) #New one as active self.patternGrid.guicallback_chooseCurrentTrack(exportDict, newCurrentTrackId) + self.transposeControls.guicallback_chooseCurrentTrack(exportDict, newCurrentTrackId) #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! @@ -240,6 +241,7 @@ class MainWindow(TemplateMainWindow): self.patternToolbar.addWidget(velocityControls) transposeControls = TransposeControls(parentScene=self.patternGrid) + self.transposeControls = transposeControls self.patternToolbar.addWidget(transposeControls) #Finally add a spacer to center all widgets diff --git a/qtgui/pattern_grid.py b/qtgui/pattern_grid.py index d3d11c7..df1abeb 100644 --- a/qtgui/pattern_grid.py +++ b/qtgui/pattern_grid.py @@ -66,7 +66,7 @@ class PatternGrid(QtWidgets.QGraphicsScene): self._tracks = {} #tr-id:exportDict #kept up to date by various callbacks. - self._zoomFactor = 1 # no save. We don't keep a qt config. + self._zoomFactor = 1 # no save. #Set color, otherwise it will be transparent in window managers or wayland that want that. self.backColor = QtGui.QColor(55, 61, 69) @@ -176,6 +176,7 @@ class PatternGrid(QtWidgets.QGraphicsScene): self._fullRedraw(newCurrentTrackId) + def callback_patternChanged(self, exportDict, force=False): """We receive the whole track as exportDict. exportDict["pattern"] is the data structure example in the class docstring. @@ -187,12 +188,24 @@ class PatternGrid(QtWidgets.QGraphicsScene): to trigger a redraw even during the track change. """ + #Check if the number of steps is different before overwriting the old cached value + if exportDict["id"] == self.parentView.parentMainWindow.currentTrackId and not exportDict["numberOfSteps"] == self._tracks[exportDict["id"]]["numberOfSteps"]: + #print ("new steps", exportDict["numberOfSteps"], len(self._steps)) + redrawStepsNeeded = True + else: + redrawStepsNeeded = False + #print ("same steps", exportDict["numberOfSteps"], len(self._steps)) + + #Cache anyway. self._tracks[exportDict["id"]] = exportDict if force or exportDict["id"] == self.parentView.parentMainWindow.currentTrackId: updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes. self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate) + if redrawStepsNeeded: + self._redrawSteps(exportDict["patternBaseLength"], forceId=exportDict["id"]) #first parameter howMany is rhtyhm, not pitches. redraw fetches pitches itself. + for step in self._steps.values(): step.off() @@ -307,7 +320,10 @@ class PatternGrid(QtWidgets.QGraphicsScene): x = event.scenePos().x() inside = x > SIZE_RIGHT_OFFSET and x < self.sceneRect().width() - SIZE_RIGHT_OFFSET - if ( row < 0 or row > 7 ) or not inside : + + maxRow = self._tracks[self.parentView.parentMainWindow.currentTrackId]["numberOfSteps"] + + if ( row < 0 or row >= maxRow ) or not inside : row = None if not row == self._lastRow: @@ -673,10 +689,13 @@ class Scale(QtWidgets.QGraphicsRectItem): self.setNoteNames(exportDict["simpleNoteNames"]) self.setScale(exportDict["scale"]) - 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.""" + We only add, show and hide. Never delete steps. + + Before using self.pitchWidgets to derive a scale for the engine we need to filter out + hidden ones. + """ stepsSoFar = len(self.pitchWidgets) @@ -718,7 +737,7 @@ class Scale(QtWidgets.QGraphicsRectItem): pitchWidget.spinBoxValueChanged() #change all current pitchWidgets def sendToEngine(self, callback=True): - result = [widget.spinBox.value() for widget in self.pitchWidgets] + result = [widget.spinBox.value() for widget in self.pitchWidgets if widget.isVisible()] #hidden pitchwidgets are old ones that are not present in the current pattenrns scale. #result.reverse() trackId = self.parentScene.parentView.parentMainWindow.currentTrackId if trackId: #startup check @@ -726,8 +745,12 @@ class Scale(QtWidgets.QGraphicsRectItem): class TransposeControls(QtWidgets.QWidget): """ + Created in mainwindow.py _populatePatternToolbar() + 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""" + 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. @@ -752,6 +775,8 @@ class TransposeControls(QtWidgets.QWidget): self.parentScene = parentScene super().__init__() + self.exportDict = None + layout = QtWidgets.QHBoxLayout() layout.setSpacing(0) layout.setContentsMargins(0,0,0,0) @@ -794,6 +819,26 @@ class TransposeControls(QtWidgets.QWidget): self._comboBoxNoteNames.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Use this scheme as note names.")) layout.addWidget(self._comboBoxNoteNames) + self._numberOfStepsChooser = QtWidgets.QSpinBox() + self._numberOfStepsChooser.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Choose how many different notes does this pattern should have.")) + self._numberOfStepsChooser.setMinimum(1) + self._numberOfStepsChooser.setMaximum(127) + self._numberOfStepsChooser.setSuffix(QtCore.QCoreApplication.translate("TransposeControls", " Notes")) + self._numberOfStepsChooser.valueChanged.connect(self._reactToNumberOfStepsChooser) + layout.addWidget(self._numberOfStepsChooser) + + def guicallback_chooseCurrentTrack(self, exportDict, newCurrentTrackId): + """Called by mainwindow chooseCurrentTrack()""" + self.exportDict = exportDict + self._numberOfStepsChooser.blockSignals(True) + self._numberOfStepsChooser.setValue(exportDict["numberOfSteps"]) + self._numberOfStepsChooser.blockSignals(False) + + def _reactToNumberOfStepsChooser(self): + self._numberOfStepsChooser.blockSignals(True) + api.resizePatternWithoutScale(self.exportDict["id"], self._numberOfStepsChooser.value()) + self._numberOfStepsChooser.blockSignals(False) + def _changeNoteNamesByDropdown(self, index): if index > 0: index -= 1 # we inserted our "Set NoteNames to" at index 0 shifting the real values +1.