Browse Source

First steps to allow more notes per pattern than 8

master
Nils 3 years ago
parent
commit
e25d0d1c17
  1. 73
      engine/api.py
  2. 36
      engine/pattern.py
  3. 1
      engine/track.py
  4. 4
      qtgui/mainwindow.py
  5. 56
      qtgui/pattern_grid.py

73
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()

36
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 = {}

1
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,

4
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.

56
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())

Loading…
Cancel
Save