Browse Source

New swing feature, including GUI slider. Only for subdivisions 2 and 4

master
Nils 3 years ago
parent
commit
e63593fa82
  1. 10
      CHANGELOG
  2. 61
      engine/api.py
  3. 10
      engine/main.py
  4. 33
      engine/pattern.py
  5. 46
      qtgui/mainwindow.py

10
CHANGELOG

@ -1,12 +1,12 @@
2021-01-15 Version 2.0.0
Two big new features increase the MAJOR version. Old save files can still be loaded.
Big new features increase the MAJOR version. Old save files can still be loaded.
The new features integrate seamlessly into the existing workflow:
Tracks can now have different measure lengths, allowing much more complex music. Added multiplication-widget next to track name.
Tracks can now have different pattern lengths, allowing much more complex music. Added multiplication-widget next to track name.
Each individual Pattern can now have any number of pitches (was fixed to 8 pitches)
Loop range can now contain multiple measures. Added GUI control next to loop button. Reminder: Looprange is for trial&error, not for production.
Each individual Pattern can now have any number of steps.
Add 'Chords'-Track to the new/empty file.
A swing slider for subgroups 2 and 4 is now available for that 90s Dance Feeling.
Added 'Chords'-Track to the new/empty file.
Remove Nuitka as dependency. Build commands stay the same.
2020-09-25 Version 1.7.0

61
engine/api.py

@ -13,12 +13,32 @@ from calfbox import cbox
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 *
from template.engine.duration import baseDurationToTraditionalNumber
from template.helper import compress
#Our own engine Modules
pass
DEFAULT_FACTOR = 1 #for the GUI.
#Swing Lookup Table for functions and callbacks
_percentToSwing_Table = {}
_swingToPercent_Table = {}
for value in range(-100, 100+1):
#Lookup table.
if value == 0:
result = 0
elif value > 80: #81% - 100% is 0.33 - 0.5
result = compress(value, 81, 100, 0.33, 0.5)
elif value > 30: #31% - 80% is 0.15 - 0.33
result = compress(value, 31, 80, 0.15, 0.32)
else:
result = compress(value, 0, 30, 0.01, 0.14)
r = round(result,8)
_percentToSwing_Table[value] = r
_swingToPercent_Table[r] = value #TODO: this is risky! it only works because we round to digits and percents are integers.
#New callbacks
class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
def __init__(self):
@ -38,6 +58,8 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
self.loopChanged = []
self.loopMeasureFactorChanged = []
self.patternLengthMultiplicatorChanged = []
self.swingChanged = []
self.swingPercentChanged = []
def _quarterNotesPerMinuteChanged(self):
"""There is one tempo for the entire song in quarter notes per mintue.
@ -187,6 +209,14 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
func(export)
self._patternChanged(track) #includes dataChanged
def _swingChanged(self):
export = session.data.swing
for func in self.swingChanged:
func(export)
for func in self.swingPercentChanged:
func(_swingToPercent_Table[export])
#Inject our derived Callbacks into the parent module
template.engine.api.callbacks = ClientCallbacks()
from template.engine.api import callbacks
@ -212,12 +242,12 @@ def startEngine(nsmClient):
callbacks._subdivisionsChanged()
callbacks._quarterNotesPerMinuteChanged()
callbacks._loopMeasureFactorChanged()
callbacks._swingChanged()
for track in session.data.tracks:
callbacks._trackMetaDataChanged(track) #for colors, scale and simpleNoteNames
callbacks._patternLengthMultiplicatorChanged(track) #for colors, scale and simpleNoteNames
session.data.buildAllTracks(buildSongDuration=True) #will set to max track length, we always have a song duration.
updatePlayback()
@ -353,6 +383,35 @@ def convert_subdivisions(value, errorHandling):
_loopNow()
return result
def set_swing(value:float):
"""A swing that feels natural is not linear. This function sets the absolute value
between -0.5 and 0.5 but you most likely want to use setSwingPercent which has a non-linear
mapping"""
if value < -0.5 or value > 0.5:
logger.warning(f"Swing can only be between -0.5 and 0.5, not {value}")
return
session.data.swing = value
session.data.buildAllTracks()
updatePlayback()
callbacks._swingChanged()
def setSwingPercent(value:int):
"""Give value between -100 and 100. 0 is "off" and default.
It will be converted to a number between -0.5 and 0.5 behind the scenes. This is the value
that gets saved.
Our function will use a lookup-table to convert percentage in a musical way.
The first 80% will be used for normal musical values. The other 20 for more extreme sounds.
"""
if value < -100 or value > 100:
logger.warning(f"Swing in percent can only be between -100 and +100, not {value}")
return
set_swing(_percentToSwing_Table[value])
def set_numberOfMeasures(value):
if session.data.numberOfMeasures == value:
return

10
engine/main.py

@ -57,6 +57,7 @@ class Data(template.engine.sequencer.Score):
self.subdivisions = 1
self.lastUsedNotenames = simpleNoteNames["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.
self.loopMeasureFactor = 1 #when looping how many at once?
self.swing = 0 #-0.5 to 0.5 See pattern.buildPattern docstring.
#Create three tracks with their first pattern activated, so 'play' after startup already produces sounding notes. This is less confusing for a new user.
self.addTrack(name="Melody A", color="#ffff00")
@ -197,6 +198,7 @@ class Data(template.engine.sequencer.Score):
"subdivisions" : self.subdivisions,
"lastUsedNotenames" : self.lastUsedNotenames,
"loopMeasureFactor" : self.loopMeasureFactor,
"swing" : self.swing,
})
return dictionary
@ -209,10 +211,15 @@ class Data(template.engine.sequencer.Score):
self.measuresPerGroup = serializedData["measuresPerGroup"]
self.subdivisions = serializedData["subdivisions"]
self.lastUsedNotenames = serializedData["lastUsedNotenames"]
if "loopMeasureFactor" in serializedData: #1.8
if "loopMeasureFactor" in serializedData: #2.0
self.loopMeasureFactor = serializedData["loopMeasureFactor"]
else:
self.loopMeasureFactor = 1
if "swing" in serializedData: #2.0
self.swing = serializedData["swing"]
else:
self.swing = 0
#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
@ -229,6 +236,7 @@ class Data(template.engine.sequencer.Score):
"subdivisions" : self.subdivisions,
"loopMeasureFactor" : self.loopMeasureFactor,
"isTransportMaster" : self.tempoMap.export()["isTransportMaster"],
"swing" : self.swing,
}

33
engine/pattern.py

@ -291,6 +291,29 @@ class Pattern(object):
patternLengthMultiplicator
_cachedTransposedScale is updated with self.scale changes and therefore already covered.
Shuffle / Swing:
subdivisions is the "group size" of the GUI. Default=1. GUI allows 1, 2, 3, 4 only.
They are the eights, triplets and 16th notes of a measure, if we assume quarters as the base duration (->whatTypeOfUnit)
We imagine shuffle as the point where the first note ends and the second begins.
This divider is shifted to the left or right (earlier/later) but the overall duration of the sum remains the same.
The start tick of the first note is not touched and the end tick of the second note is not touched.
Shuffle is a value between -0.5 and 0.5, where 0 means no difference.
0.5 makes the second note so short it doesn't really exist
-0.5 makes the first extremely short.
Value experiments:
0.05 is very subtle but if you know that it is there you'll hear it
0.1 is already very audible.
0.15 is smooth, jazzy. Good for subdivisions=2, too sharp for subdivisions=4
0.25 is "hopping", does not feel like swing at all, but "classical"
0.333 is very sharp.
A lookup-table function to musically map from -100% to 100% has been provided by the API
api.swingPercentToAbsolute
"""
cacheHash = (scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions, tuple(self.scale), self._exportCacheVersion, patternLengthMultiplicator)
try:
@ -304,12 +327,22 @@ class Pattern(object):
exportPattern = bytes()
shuffle = int(self.parentTrack.parentData.swing * whatTypeOfUnit)
for noteDict in self.exportCache:
index = noteDict["index"]
startTick = index * whatTypeOfUnit
endTick = startTick + noteDict["factor"] * whatTypeOfUnit
startTick /= subdivisions
endTick /= subdivisions
if subdivisions > 1 and shuffle != 0 and subdivisions % 2 == 0:
positionInSubdivisionGroup = index % subdivisions #0 is first note
if positionInSubdivisionGroup % 2 == 0: #main beats
endTick += shuffle
else: #off beats
startTick += shuffle
startTick = int(startTick)
endTick = int(endTick)

46
qtgui/mainwindow.py

@ -226,6 +226,7 @@ class MainWindow(TemplateMainWindow):
self.chooseCurrentTrack(newTrackExporDict)
def _populatePatternToolbar(self):
"""Called once at the creation of the GUI"""
self.patternToolbar.contextMenuEvent = api.nothing #remove that annoying Checkbox context menu
spacerItemLeft = QtWidgets.QWidget()
@ -249,6 +250,7 @@ class MainWindow(TemplateMainWindow):
def _populateToolbar(self):
"""Called once at the creation of the GUI"""
self.ui.toolBar.contextMenuEvent = api.nothing #remove that annoying Checkbox context menu
#Designer Buttons
@ -394,6 +396,44 @@ class MainWindow(TemplateMainWindow):
api.callbacks.timeSignatureChanged.append(callback_setTimeSignature)
#Swing Controls
swingControls = QtWidgets.QSlider()
swingControls.setOrientation(1)
swingControls.setMinimum(0)
swingControls.setMaximum(100)
#swingControls.setAutoFillBackground(True)
op=QtWidgets.QGraphicsOpacityEffect(self)
swingControls.setGraphicsEffect(op)
swingControls.setMaximumSize(200, 40) #w, h. We don't care about h
opL=QtWidgets.QGraphicsOpacityEffect(self)
swingLabel = QtWidgets.QLabel("")
swingLabel.setGraphicsEffect(opL)
def handleCallback_swingPercentChanged(value):
swingLabel.setText(f"{value}% Swing:") #no translation.
swingControls.blockSignals(True)
swingControls.setValue(value)
swingControls.blockSignals(False)
api.callbacks.swingPercentChanged.append(handleCallback_swingPercentChanged)
def swingControls_ValueChanged():
"""Our own gui change that gets send to the engine"""
swingControls.blockSignals(True)
api.setSwingPercent(swingControls.value())
swingControls.blockSignals(False)
swingControls.valueChanged.connect(swingControls_ValueChanged)
def swingControls_Subdivisions(value):
"""Engine allows swing only for subdivision grouping 2 and 4.
Switch off the slider"""
if not value % 2 == 0:
#Hide or setVisible does not work here?!!??! the widget only flickers
op.setOpacity(0.00) #fuck it. Workaround for hide.
opL.setOpacity(0.00)
swingControls.setEnabled(False)
else:
op.setOpacity(1.00)
opL.setOpacity(1.00)
swingControls.setEnabled(True)
api.callbacks.subdivisionsChanged.append(swingControls_Subdivisions)
#Add all to Toolbar
def spacer():
"""Insert a spacing widget. positions depends on execution order"""
@ -432,6 +472,12 @@ class MainWindow(TemplateMainWindow):
self.ui.toolBar.addWidget(whatTypeOfUnit)
spacer()
swingLabel.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Set the swing factor. 0 is off. Usually you want positive values."))
self.ui.toolBar.addWidget(swingLabel)
self.ui.toolBar.addWidget(swingControls)
spacer()
def zoom(self, scaleFactor:float):
pass
def stretchXCoordinates(self, factor):

Loading…
Cancel
Save