You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
966 lines
38 KiB
966 lines
38 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import logging; logger = logging.getLogger(__name__); logger.info("import")
|
|
|
|
#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 *
|
|
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):
|
|
super().__init__()
|
|
|
|
self.timeSignatureChanged = []
|
|
self.scoreChanged = []
|
|
self.numberOfMeasuresChanged = []
|
|
self.trackStructureChanged = []
|
|
self.trackMetaDataChanged = []
|
|
self.patternChanged = []
|
|
self.stepChanged = []
|
|
self.removeStep = []
|
|
self.exportCacheChanged = []
|
|
self.subdivisionsChanged = []
|
|
self.quarterNotesPerMinuteChanged = []
|
|
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.
|
|
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.tempoMap.isTransportMaster:
|
|
export = session.data.tempoMap.getQuarterNotesPerMinute()
|
|
else:
|
|
export = None
|
|
for func in self.quarterNotesPerMinuteChanged:
|
|
func(export)
|
|
callbacks._dataChanged()
|
|
|
|
def _setPlaybackTicks(self): #Differs from the template because it has subdivisions.
|
|
ppqn = cbox.Transport.status().pos_ppqn * session.data.subdivisions
|
|
status = playbackStatus()
|
|
for func in self.setPlaybackTicks:
|
|
func(ppqn, status)
|
|
|
|
def _loopChanged(self, measurenumber, loopStart, loopEnd):
|
|
export = measurenumber
|
|
for func in self.loopChanged:
|
|
func(export)
|
|
|
|
def _loopMeasureFactorChanged(self):
|
|
"""Very atomic callback. Used only for one value: how many measures are in one loop"""
|
|
export = session.data.loopMeasureFactor
|
|
for func in self.loopMeasureFactorChanged:
|
|
func(export)
|
|
|
|
def _timeSignatureChanged(self):
|
|
nr = session.data.howManyUnits
|
|
typ = session.data.whatTypeOfUnit
|
|
for func in self.timeSignatureChanged:
|
|
func(nr, typ)
|
|
|
|
##All patterns and tracks need updates:
|
|
for track in session.data.tracks:
|
|
self._patternChanged(track)
|
|
|
|
self._subdivisionsChanged() #update subdivisions. We do not include them in the score or track export on purpose.
|
|
callbacks._dataChanged()
|
|
|
|
def _subdivisionsChanged(self):
|
|
"""Subdivisions are tricky, therefore we keep them isolated in their own callback.
|
|
You don't need to redraw anything if you don't want to. One recommendation is to
|
|
draw every n step a little more important (bigger, different color).
|
|
where n = subdivions
|
|
|
|
We also place JACK BBT via tempoMap here because we need it every time the time sig
|
|
changes (which calls _subdivisionsChanged) and if subdivisions change.
|
|
"""
|
|
typ = baseDurationToTraditionalNumber[session.data.whatTypeOfUnit]
|
|
nr = session.data.howManyUnits
|
|
tradNr = int(nr / session.data.subdivisions)
|
|
#JACK BBT for Timebase Master. No matter if we are master at the moment or not.
|
|
if tradNr == nr / session.data.subdivisions:
|
|
#Easier to read than the else case. Not possible with 9 Steps per Pattern in Groups of 2 because that is a 4.5/4 timesig.
|
|
session.data.tempoMap.setTimeSignature(tradNr, typ)
|
|
else:
|
|
#Always possible, compared to first if case.
|
|
session.data.tempoMap.setTimeSignature(nr, typ*session.data.subdivisions)
|
|
|
|
export = session.data.subdivisions
|
|
for func in self.subdivisionsChanged:
|
|
func(export)
|
|
callbacks._dataChanged()
|
|
|
|
|
|
def _scoreChanged(self):
|
|
"""This includes the time signature as well, but is not send on a timesig change.
|
|
A timesig change needs to update all tracks as playback as well as for the GUI
|
|
so it is its own callback.
|
|
Use this for fast and inexpensive updates like drawing a label or adjusting the
|
|
GUI that shows your measure groups (a label each 8 measures or so)"""
|
|
export = session.data.export()
|
|
for func in self.scoreChanged:
|
|
func(export)
|
|
callbacks._dataChanged()
|
|
|
|
def _exportCacheChanged(self, track):
|
|
"""Send the export cache for GUI caching reasons. Don't react by redrawing immediately!
|
|
This is sent often, redundantly and more than you need.
|
|
Example: If you only show one pattern at the same time use this to cache the data
|
|
in all hidden patterns and redraw only if you change the active pattern.
|
|
You can react to real changes in your active pattern by using patternChanged
|
|
and stepChanged."""
|
|
export = track.export()
|
|
for func in self.exportCacheChanged:
|
|
func(export)
|
|
|
|
def _patternChanged(self, track):
|
|
"""each track has only one pattern. We can identify the pattern by track and vice versa.
|
|
Don't use this to react to clicks on the pattern editor. Use stepChanged instead and
|
|
keep book of your incremental updates.
|
|
This is used for the whole pattern: timesig changes, invert, clean etc.
|
|
"""
|
|
export = track.export()
|
|
self._exportCacheChanged(track)
|
|
for func in self.patternChanged:
|
|
func(export)
|
|
callbacks._dataChanged()
|
|
|
|
def _stepChanged(self, track, stepDict):
|
|
"""A simple GUI will most like not listen to that callback since they
|
|
already changed the step on their side. Only useful for parallel
|
|
views.
|
|
We do not export anything but just sent back the change we received
|
|
as dict message."""
|
|
self._exportCacheChanged(track)
|
|
for func in self.stepChanged:
|
|
func(stepDict)
|
|
callbacks._dataChanged()
|
|
|
|
def _removeStep(self, track, index, pitch):
|
|
"""Opposite of _stepChanged"""
|
|
self._exportCacheChanged(track)
|
|
for func in self.stepChanged:
|
|
func(index, pitch)
|
|
callbacks._dataChanged()
|
|
|
|
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)
|
|
callbacks._dataChanged()
|
|
|
|
def _trackMetaDataChanged(self, track):
|
|
"""a low cost function that should not trigger anything costly to redraw
|
|
but some text and simple widgets."""
|
|
export = track.export()
|
|
for func in self.trackMetaDataChanged:
|
|
func(export)
|
|
callbacks._dataChanged()
|
|
|
|
def _numberOfMeasuresChanged(self):
|
|
export = session.data.export()
|
|
for func in self.numberOfMeasuresChanged:
|
|
func(export)
|
|
callbacks._dataChanged()
|
|
|
|
def _patternLengthMultiplicatorChanged(self, track):
|
|
export = track.export()
|
|
for func in self.patternLengthMultiplicatorChanged:
|
|
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
|
|
|
|
_templateStartEngine = startEngine
|
|
|
|
def updatePlayback():
|
|
#TODO: use template.sequencer.py internal updates instead
|
|
cbox.Document.get_song().update_playback()
|
|
|
|
def startEngine(nsmClient):
|
|
_templateStartEngine(nsmClient)
|
|
|
|
session.inLoopMode = None # remember if we are in loop mode or not. Positive value is None or a tuple with start and end
|
|
|
|
#Send initial Callbacks to create the first GUI state.
|
|
#The order of initial callbacks must not change to avoid GUI problems.
|
|
#For example it is important that the tracks get created first and only then the number of measures
|
|
logger.info("Sending initial callbacks to GUI")
|
|
callbacks._numberOfTracksChanged()
|
|
callbacks._timeSignatureChanged()
|
|
callbacks._numberOfMeasuresChanged()
|
|
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()
|
|
logger.info("Patroneo api startEngine complete")
|
|
|
|
|
|
def _loopOff():
|
|
session.data.buildSongDuration() #no parameter removes the loop
|
|
updatePlayback()
|
|
session.inLoopMode = None
|
|
callbacks._loopChanged(None, None, None)
|
|
|
|
def _loopNow():
|
|
now = cbox.Transport.status().pos_ppqn
|
|
_setLoop(now)
|
|
|
|
def _setLoop(loopMeasureAroundPpqn:int):
|
|
"""This function is used with context.
|
|
The loopFactor, how many measures are looped, is saved value """
|
|
if loopMeasureAroundPpqn < 0:
|
|
_loopOff()
|
|
return
|
|
|
|
loopStart, loopEnd = session.data.buildSongDuration(loopMeasureAroundPpqn)
|
|
session.data._lastLoopStart = loopStart
|
|
|
|
updatePlayback()
|
|
session.inLoopMode = (loopStart, loopEnd)
|
|
|
|
assert loopStart <= loopMeasureAroundPpqn < loopEnd
|
|
if not playbackStatus():
|
|
cbox.Transport.play()
|
|
|
|
oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions
|
|
|
|
measurenumber, rest = divmod(loopStart, oneMeasureInTicks)
|
|
|
|
callbacks._loopChanged(int(measurenumber), loopStart, loopEnd)
|
|
|
|
def setLoopMeasureFactor(newValue:int):
|
|
"""How many measures are looped at once."""
|
|
if newValue < 1:
|
|
newValue = 1
|
|
session.data.loopMeasureFactor = newValue
|
|
callbacks._loopMeasureFactorChanged()
|
|
if session.inLoopMode:
|
|
_setLoop(session.data._lastLoopStart)
|
|
|
|
def toggleLoop():
|
|
"""Plays the current measure as loop.
|
|
Current measure is where the playback cursor is
|
|
|
|
session.inLoopMode is a tuple (start, end)
|
|
"""
|
|
if session.inLoopMode:
|
|
_loopOff()
|
|
else:
|
|
_loopNow()
|
|
|
|
def rewind():
|
|
"""template.toStart, but removes our loop"""
|
|
_loopOff()
|
|
toStart()
|
|
|
|
def seek(value):
|
|
"""override template one, which does not have a loop"""
|
|
if value < 0:
|
|
value = 0
|
|
if session.inLoopMode and not session.inLoopMode[0] <= value < session.inLoopMode[1]: #if you seek outside the loop the loop will be destroyed.
|
|
toggleLoop()
|
|
cbox.Transport.seek_ppqn(value)
|
|
|
|
##Score
|
|
def set_quarterNotesPerMinute(value):
|
|
if value is None:
|
|
session.data.tempoMap.isTransportMaster = False #triggers rebuild
|
|
elif value == "on":
|
|
assert not session.data.tempoMap.isTransportMaster
|
|
#keep old bpm value. 120 bpm is default.
|
|
session.data.tempoMap.isTransportMaster = True #triggers rebuild
|
|
else:
|
|
assert value > 0
|
|
session.data.tempoMap.setQuarterNotesPerMinute(value)
|
|
session.data.tempoMap.isTransportMaster = True #triggers rebuild
|
|
#Does not need track rebuilding
|
|
updatePlayback()
|
|
callbacks._quarterNotesPerMinuteChanged()
|
|
|
|
|
|
def set_whatTypeOfUnit(ticks):
|
|
"""Denominator of Time Signature"""
|
|
if session.data.whatTypeOfUnit == ticks: return
|
|
session.data.whatTypeOfUnit = ticks
|
|
session.data.buildAllTracks()
|
|
if session.inLoopMode:
|
|
_loopNow()
|
|
updatePlayback()
|
|
callbacks._timeSignatureChanged()
|
|
|
|
def set_howManyUnits(value):
|
|
"""Numerator of Time Signature"""
|
|
if session.data.howManyUnits == value: return
|
|
session.data.howManyUnits = value
|
|
session.data.buildAllTracks()
|
|
if session.inLoopMode:
|
|
_loopNow()
|
|
updatePlayback()
|
|
callbacks._timeSignatureChanged()
|
|
|
|
|
|
def set_subdivisions(value):
|
|
if session.data.subdivisions == value: return
|
|
session.data.subdivisions = value
|
|
session.data.buildAllTracks()
|
|
if session.inLoopMode:
|
|
_loopNow()
|
|
updatePlayback()
|
|
callbacks._subdivisionsChanged()
|
|
|
|
def convert_subdivisions(value, errorHandling):
|
|
""""errorHandling can be fail, delete or merge"""
|
|
if session.data.subdivisions == value: return
|
|
result = session.data.convertSubdivisions(value, errorHandling)
|
|
if result:
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
callbacks._timeSignatureChanged() #includes subdivisions
|
|
for tr in session.data.tracks:
|
|
callbacks._patternChanged(tr)
|
|
else:
|
|
callbacks._subdivisionsChanged() #to reset the GUI value back to the working one.
|
|
if session.inLoopMode:
|
|
_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
|
|
session.data.numberOfMeasures = value
|
|
session.data.buildSongDuration()
|
|
updatePlayback()
|
|
callbacks._numberOfMeasuresChanged()
|
|
callbacks._scoreChanged() #redundant but cheap and convenient
|
|
|
|
def set_measuresPerGroup(value):
|
|
if session.data.measuresPerGroup == value:
|
|
return
|
|
session.data.measuresPerGroup = value
|
|
#No playback change
|
|
callbacks._scoreChanged()
|
|
|
|
def changeTrackName(trackId, name):
|
|
track = session.data.trackById(trackId)
|
|
track.sequencerInterface.name = " ".join(name.split())
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def changeTrackColor(trackId, colorInHex):
|
|
"""Expects "#rrggbb"""
|
|
track = session.data.trackById(trackId)
|
|
assert len(colorInHex) == 7, colorInHex
|
|
track.color = colorInHex
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def addTrack(scale=None):
|
|
if scale:
|
|
assert type(scale) == tuple
|
|
session.data.addTrack(scale=scale)
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
def createSiblingTrack(trackId):
|
|
"""Create a new track with scale, color and jack midi out the same as the given track.
|
|
The jack midi out will be independent after creation, but connected to the same instrument
|
|
(if any)"""
|
|
track = session.data.trackById(trackId)
|
|
assert type(track.pattern.scale) == tuple
|
|
newTrack = session.data.addTrack(name=track.sequencerInterface.name, scale=track.pattern.scale, color=track.color, simpleNoteNames=track.pattern.simpleNoteNames) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2"
|
|
newTrack.pattern.averageVelocity = track.pattern.averageVelocity
|
|
newTrack.patternLengthMultiplicator = track.patternLengthMultiplicator
|
|
jackConnections = cbox.JackIO.get_connected_ports(track.sequencerInterface.cboxPortName())
|
|
for port in jackConnections:
|
|
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)
|
|
newTrackAgain = session.data.tracks.pop(newIndex)
|
|
assert newTrackAgain is newTrack
|
|
session.data.tracks.insert(oldIndex+1, newTrackAgain)
|
|
callbacks._numberOfTracksChanged()
|
|
return newTrack.export()
|
|
|
|
def deleteTrack(trackId):
|
|
track = session.data.trackById(trackId)
|
|
session.data.deleteTrack(track)
|
|
if not session.data.tracks: #always keep at least one track
|
|
session.data.addTrack()
|
|
updatePlayback()
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
def moveTrack(trackId, newIndex):
|
|
"""index is 0 based"""
|
|
track = session.data.trackById(trackId)
|
|
oldIndex = session.data.tracks.index(track)
|
|
if not oldIndex == newIndex:
|
|
session.data.tracks.pop(oldIndex)
|
|
session.data.tracks.insert(newIndex, track)
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
def setTrackPatternLengthMultiplicator(trackId, newMultiplicator:int):
|
|
if newMultiplicator < 1 or not isinstance(newMultiplicator, int):
|
|
return #Invalid input
|
|
|
|
track = session.data.trackById(trackId)
|
|
track.patternLengthMultiplicator = newMultiplicator
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
|
|
#Order is important! Otherwise the GUI doesn't know that new empty steps need to exist to fill in.
|
|
callbacks._patternLengthMultiplicatorChanged(track)
|
|
callbacks._patternChanged(track)
|
|
|
|
#Track Switches
|
|
|
|
def setSwitches(trackId, setOfPositions, newBool):
|
|
track = session.data.trackById(trackId)
|
|
if newBool:
|
|
track.structure = track.structure.union(setOfPositions) #add setOfPositions to the existing one
|
|
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()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def setSwitch(trackId, position, newBool):
|
|
track = session.data.trackById(trackId)
|
|
if newBool:
|
|
if position in track.structure: return
|
|
track.structure.add(position)
|
|
else:
|
|
if not position in track.structure: return
|
|
track.structure.remove(position)
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
def trackInvertSwitches(trackId):
|
|
track = session.data.trackById(trackId)
|
|
"""
|
|
if track.structure:
|
|
new = set(i for i in range(max(track.structure)))
|
|
track.structure = new.difference(track.structure)
|
|
else:
|
|
track.structure = set(i for i in range(session.data.numberOfMeasures))
|
|
"""
|
|
new = set(i for i in range(session.data.numberOfMeasures))
|
|
track.structure = new.difference(track.structure)
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def trackOffAllSwitches(trackId):
|
|
track = session.data.trackById(trackId)
|
|
track.structure = set()
|
|
track.buildTrack()
|
|
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()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def trackMergeCopyFrom(sourceTrackId, targetTrackId):
|
|
if not sourceTrackId == targetTrackId:
|
|
sourceTrack = session.data.trackById(sourceTrackId)
|
|
targetTrack = session.data.trackById(targetTrackId)
|
|
targetTrack.structure = targetTrack.structure.union(sourceTrack.structure)
|
|
targetTrack.whichPatternsAreScaleTransposed.update(sourceTrack.whichPatternsAreScaleTransposed)
|
|
targetTrack.whichPatternsAreHalftoneTransposed.update(sourceTrack.whichPatternsAreHalftoneTransposed)
|
|
targetTrack.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(targetTrack)
|
|
|
|
def trackPatternReplaceFrom(sourceTrackId, targetTrackId):
|
|
if not sourceTrackId == targetTrackId:
|
|
sourceTrack = session.data.trackById(sourceTrackId)
|
|
targetTrack = session.data.trackById(targetTrackId)
|
|
|
|
copyPattern = sourceTrack.pattern.copy(newParentTrack = targetTrack)
|
|
targetTrack.pattern = copyPattern
|
|
|
|
targetTrack.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(targetTrack)
|
|
|
|
def setSwitchScaleTranspose(trackId, position, transpose):
|
|
"""Scale transposition is flipped. lower value means higher pitch"""
|
|
track = session.data.trackById(trackId)
|
|
track.whichPatternsAreScaleTransposed[position] = transpose
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
def setSwitchHalftoneTranspose(trackId, position, transpose):
|
|
"""Halftone transposition is not flipped. Higher value means higher pitch"""
|
|
track = session.data.trackById(trackId)
|
|
track.whichPatternsAreHalftoneTransposed[position] = transpose
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
def insertSilence(howMany:int, beforeMeasureNumber:int):
|
|
"""Insert empty measures into all tracks.
|
|
Parameters are un-multiplied."""
|
|
|
|
#In each track shift every switch to the right if it is before the dividing measure number
|
|
#Keep the concept of a "group" even if there are multiplicators in the track.
|
|
#If you insert 4 normal measures then a multiplicator-track of two gets only 2 new ones.
|
|
for track in session.data.tracks:
|
|
thisTrackWhere = beforeMeasureNumber // track.patternLengthMultiplicator #integer division
|
|
thisTrackHowMany = howMany // track.patternLengthMultiplicator #integer division
|
|
track.structure = set( (switch + thisTrackHowMany if switch >= thisTrackWhere else switch) for switch in track.structure )
|
|
track.whichPatternsAreScaleTransposed = { (k+thisTrackHowMany if k >= thisTrackWhere else k):v for k,v in track.whichPatternsAreScaleTransposed.items() }
|
|
track.whichPatternsAreHalftoneTransposed = { (k+thisTrackHowMany if k >= thisTrackWhere else k):v for k,v in track.whichPatternsAreHalftoneTransposed.items() }
|
|
callbacks._trackStructureChanged(track)
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
def duplicateSwitchGroup(startMeasureForGroup:int, endMeasureExclusive:int):
|
|
"""startMeasureForGroup and endMeasureExclusive are in the global, un-multiplied measure counting
|
|
format."""
|
|
|
|
groupSize = endMeasureExclusive-startMeasureForGroup
|
|
insertSilence(groupSize, endMeasureExclusive) #insert silence handles multiplicator-tracks on its own
|
|
|
|
for track in session.data.tracks:
|
|
thisTrackStartMeasure = startMeasureForGroup // track.patternLengthMultiplicator #integer division
|
|
thisTrackEndMeasure = endMeasureExclusive // track.patternLengthMultiplicator
|
|
thisGroupSize = groupSize // track.patternLengthMultiplicator
|
|
|
|
for switch in range(thisTrackStartMeasure+thisGroupSize, thisTrackEndMeasure+thisGroupSize): #One group after the given one.
|
|
if switch-thisGroupSize in track.structure:
|
|
track.structure.add(switch)
|
|
if switch-thisGroupSize in track.whichPatternsAreScaleTransposed:
|
|
track.whichPatternsAreScaleTransposed[switch] = track.whichPatternsAreScaleTransposed[switch-thisGroupSize]
|
|
if switch-thisGroupSize in track.whichPatternsAreHalftoneTransposed:
|
|
track.whichPatternsAreHalftoneTransposed[switch] = track.whichPatternsAreHalftoneTransposed[switch-thisGroupSize]
|
|
callbacks._trackStructureChanged(track)
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
def clearSwitchGroupTranspositions(startMeasureForGroup:int, endMeasureExclusive:int):
|
|
"""startMeasureForGroup and endMeasureExclusive are in the global, un-multiplied measure counting
|
|
format."""
|
|
for track in session.data.tracks:
|
|
thisTrackStartMeasure = startMeasureForGroup // track.patternLengthMultiplicator #integer division
|
|
thisTrackEndMeasure = endMeasureExclusive // track.patternLengthMultiplicator
|
|
for switch in range(thisTrackStartMeasure, thisTrackEndMeasure):
|
|
if switch in track.whichPatternsAreScaleTransposed:
|
|
del track.whichPatternsAreScaleTransposed[switch]
|
|
if switch in track.whichPatternsAreHalftoneTransposed:
|
|
del track.whichPatternsAreHalftoneTransposed[switch]
|
|
callbacks._trackStructureChanged(track)
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
def deleteSwitches(howMany, fromMeasureNumber):
|
|
"""Parameters are un-multiplied measures."""
|
|
for track in session.data.tracks:
|
|
thisTrackHowMany = howMany // track.patternLengthMultiplicator #integer division
|
|
thisTrackWhere = fromMeasureNumber // track.patternLengthMultiplicator #integer division
|
|
|
|
new_structure = set()
|
|
|
|
for switch in track.structure:
|
|
if switch < thisTrackWhere:
|
|
new_structure.add(switch)
|
|
elif switch >= thisTrackWhere+thisTrackHowMany: #like a text editor let gravitate left into the hole left by the deleted range
|
|
new_structure.add(switch-thisTrackHowMany)
|
|
#else: #discard all in range to delete
|
|
track.structure = new_structure
|
|
|
|
new_scaleTransposed = dict()
|
|
for k,v in track.whichPatternsAreScaleTransposed.items():
|
|
if k < thisTrackWhere:
|
|
new_scaleTransposed[k] = v
|
|
elif k >= thisTrackWhere+thisTrackHowMany: #like a text editor let gravitate left into the hole left by the deleted range
|
|
new_scaleTransposed[k-thisTrackHowMany] = v
|
|
#else: #discard all in range to delete
|
|
track.whichPatternsAreScaleTransposed = new_scaleTransposed
|
|
|
|
new_halftoneTransposed = dict()
|
|
for k,v in track.whichPatternsAreHalftoneTransposed.items():
|
|
if k < thisTrackWhere:
|
|
new_halftoneTransposed[k] = v
|
|
elif k >= thisTrackWhere+thisTrackHowMany: #like a text editor let gravitate left into the hole left by the deleted range
|
|
new_halftoneTransposed[k-thisTrackHowMany] = v
|
|
#else: #discard all in range to delete
|
|
track.whichPatternsAreHalftoneTransposed = new_halftoneTransposed
|
|
|
|
callbacks._trackStructureChanged(track)
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
#Pattern Steps
|
|
def setPattern(trackId, patternList):
|
|
"""Change the whole pattern, send a callback with the whole pattern"""
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.data = patternList
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
callbacks._patternChanged(track)
|
|
|
|
def getAverageVelocity(trackId):
|
|
"""If a GUI wants to add a new note and choose a sensible velocity it can use this function"""
|
|
return session.data.trackById(trackId).pattern.averageVelocity
|
|
|
|
def setStep(trackId, stepExportDict):
|
|
"""This is an atomic operation that only sets one switch and
|
|
only sends that switch back via callback. A simple GUI
|
|
will most like not listen to that callback since they
|
|
already changed the step on their side. Only useful for parallel
|
|
views."""
|
|
track = session.data.trackById(trackId)
|
|
oldNote = track.pattern.stepByIndexAndPitch(index=stepExportDict["index"], pitch=stepExportDict["pitch"])
|
|
if oldNote: #modify existing note
|
|
oldNoteIndex = track.pattern.data.index(oldNote)
|
|
track.pattern.data.remove(oldNote)
|
|
track.pattern.data.insert(oldNoteIndex, stepExportDict) #for what its worth, insert at the old place. It doesn't really matter though.
|
|
else: #new note
|
|
track.pattern.data.append(stepExportDict)
|
|
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._stepChanged(track, stepExportDict)
|
|
|
|
def removeStep(trackId, index, pitch):
|
|
"""Reverse of setStep"""
|
|
track = session.data.trackById(trackId)
|
|
oldNote = track.pattern.stepByIndexAndPitch(index, pitch)
|
|
track.pattern.data.remove(oldNote)
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._removeStep(track, index, pitch)
|
|
|
|
def setScale(trackId, scale, callback = True):
|
|
"""Expects a scale list or tuple from lowest index to highest.
|
|
Actual pitches don't matter."""
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.scale = scale
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
if callback:
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def setSimpleNoteNames(trackId, simpleNoteNames):
|
|
"""note names is a list of strings with length 128. One name for each midi note.
|
|
It is saved to file"""
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.simpleNoteNames = simpleNoteNames
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def transposeHalftoneSteps(trackId, steps):
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.scale = [midipitch+steps for midipitch in track.pattern.scale]
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def patternInvertSteps(trackId):
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.invert()
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternOnAllSteps(trackId):
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.fill()
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternOffAllSteps(trackId):
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.empty()
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternInvertRow(trackId, pitchindex):
|
|
"""Pitchindex is the row"""
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.invertRow(pitchindex)
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternClearRow(trackId, pitchindex):
|
|
"""Pitchindex is the row.
|
|
Index is the column"""
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.clearRow(pitchindex)
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternRowRepeatFromStep(trackId, pitchindex, index):
|
|
"""Pitchindex is the row.
|
|
Index is the column"""
|
|
track = session.data.trackById(trackId)
|
|
track.pattern.repeatFromStep(pitchindex, index)
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternRowChangeVelocity(trackId, pitchindex, delta):
|
|
track = session.data.trackById(trackId)
|
|
for note in track.pattern.getRow(pitchindex):
|
|
new = note["velocity"] + delta
|
|
note["velocity"] = min(max(new,0), 127)
|
|
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
|
|
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
|
|
#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.
|
|
"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... also broken > 2 octaves
|
|
}
|
|
major.reverse()
|
|
for l in schemesDict.values():
|
|
l.reverse()
|
|
|
|
#Ordered version
|
|
schemes = [
|
|
"Major",
|
|
"Minor",
|
|
"Dorian",
|
|
"Phrygian",
|
|
"Lydian",
|
|
"Mixolydian",
|
|
"Locrian",
|
|
"Blues",
|
|
"Hollywood",
|
|
"Chromatic",
|
|
]
|
|
|
|
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] #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()
|
|
updatePlayback()
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def changePatternVelocity(trackId, steps):
|
|
track = session.data.trackById(trackId)
|
|
for note in track.pattern.data:
|
|
new = note["velocity"] + steps
|
|
note["velocity"] = min(max(new,0), 127)
|
|
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
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):
|
|
track = session.data.trackById(trackId)
|
|
midipitch = track.pattern.scale[row]
|
|
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.sequencerInterface.cboxMidiOutUuid)
|
|
|
|
|
|
|