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.
1664 lines
76 KiB
1664 lines
76 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
|
|
|
|
This application is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import logging; logger = logging.getLogger(__name__); logger.info("import")
|
|
|
|
#Standard Library Modules
|
|
from typing import List, Set, Dict, Tuple
|
|
|
|
#Third Party Modules
|
|
from template.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 Modules
|
|
from engine.input_apcmini import apcMiniInput
|
|
|
|
|
|
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 = []
|
|
self.currentTrackChanged = []
|
|
|
|
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 and offset
|
|
ppqn = cbox.Transport.status().pos_ppqn * session.data.subdivisions - session.data.cachedOffsetInTicks
|
|
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, factor):
|
|
"""Opposite of _stepChanged"""
|
|
self._exportCacheChanged(track)
|
|
stepDict = {"index": index,
|
|
"factor": factor,
|
|
"pitch": pitch,
|
|
"velocity": 0, #it is off.
|
|
}
|
|
for func in self.removeStep:
|
|
func(stepDict)
|
|
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):
|
|
"""The whole song got longer or shorter.
|
|
Number of measures is the same for all tracks."""
|
|
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
|
|
self._subdivisionsChanged()
|
|
callbacks._dataChanged()
|
|
|
|
def _swingChanged(self):
|
|
"""Global for the whole song"""
|
|
export = session.data.swing
|
|
for func in self.swingChanged:
|
|
func(export)
|
|
|
|
for func in self.swingPercentChanged:
|
|
func(_swingToPercent_Table[export])
|
|
callbacks._dataChanged()
|
|
|
|
def _currentTrackChanged(self, track):
|
|
"""The engine has no concept of a current track. This is a callback to sync different
|
|
GUIs and midi controllers that use the system of a current track.
|
|
Therefore this callback will never get called on engine changes.
|
|
|
|
We send this once after load (always track 0) and then non-engine is responsible for calling
|
|
the api function api.changeCurrentTrack(trackId)
|
|
"""
|
|
export = track.export()
|
|
for func in self.currentTrackChanged:
|
|
func(export)
|
|
#no data changed.
|
|
|
|
|
|
|
|
|
|
|
|
#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, additionalData:dict={}):
|
|
_templateStartEngine(nsmClient, additionalData) #loads save files or creates empty structure.
|
|
|
|
session.inLoopMode = None # remember if we are in loop mode or not. Positive value is None or a tuple with start and end
|
|
|
|
#Activate apc mini controller
|
|
apcMiniInput.start()
|
|
apcMiniInput.setMidiInputActive(True) #MidiInput is just the general "activate processing"
|
|
#Send the current track, to at least tell apcMini where we are.
|
|
changeCurrentTrack(id(session.data.tracks[0])) # See callback docstring. This is purely for GUI and midi-controller convenience. No engine data is touched.
|
|
|
|
#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 and APCmini")
|
|
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
|
|
|
|
#loopMeasureAroundPpqn = max(0, loopMeasureAroundPpqn + session.data.cachedOffsetInTicks)
|
|
loopMeasureAroundPpqn = loopMeasureAroundPpqn - session.data.cachedOffsetInTicks
|
|
|
|
loopStart, loopEnd = session.data.buildSongDuration(loopMeasureAroundPpqn) #includes global tick offset
|
|
session.data._lastLoopStart = loopStart
|
|
|
|
updatePlayback()
|
|
session.inLoopMode = (loopStart, loopEnd)
|
|
|
|
assert loopStart <= loopMeasureAroundPpqn+session.data.cachedOffsetInTicks < loopEnd, (loopStart, loopMeasureAroundPpqn, loopEnd)
|
|
if not playbackStatus():
|
|
cbox.Transport.play()
|
|
|
|
oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions
|
|
|
|
measurenumber, rest = divmod(loopStart-session.data.cachedOffsetInTicks, oneMeasureInTicks) #We substract the offset from the measure number because for a GUI it is still the visible measure number
|
|
|
|
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()
|
|
|
|
value = max(0, value + session.data.cachedOffsetInTicks)
|
|
cbox.Transport.seek_ppqn(value)
|
|
|
|
|
|
def seekMeasureLeft():
|
|
"""This skips one base measure, not the multiplicator one"""
|
|
now = cbox.Transport.status().pos_ppqn
|
|
oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions
|
|
seek(now - oneMeasureInTicks)
|
|
|
|
def seekMeasureRight():
|
|
"""This skips one base measure, not the multiplicator one"""
|
|
now = cbox.Transport.status().pos_ppqn
|
|
oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions
|
|
seek(now + oneMeasureInTicks)
|
|
|
|
|
|
def getGlobalOffset():
|
|
"""Return the current offsets in full measures + free tick value 3rd: Cached abolute tick value
|
|
gets updated everytime the time signature changes or setGlobalOffset is called"""
|
|
return session.data.globalOffsetMeasures, session.data.globalOffsetTicks, session.data.cachedOffsetInTicks
|
|
|
|
|
|
def setGlobalOffset(fullMeasures, absoluteTicks):
|
|
session.history.register(lambda f=session.data.globalOffsetMeasures, t=session.data.globalOffsetTicks: setGlobalOffset(f,t), descriptionString="Global Rhythm Offset")
|
|
session.data.globalOffsetMeasures = fullMeasures
|
|
session.data.globalOffsetTicks = absoluteTicks
|
|
session.data.buildAllTracks() #includes refreshing the tick offset cache
|
|
updatePlayback()
|
|
|
|
##Score
|
|
def set_quarterNotesPerMinute(value):
|
|
"""Transport Master is set implicitly. If value == None Patroneo will switch into
|
|
JackTransport Slave mode"""
|
|
if session.data.tempoMap.isTransportMaster:
|
|
oldValue = session.data.tempoMap.getQuarterNotesPerMinute()
|
|
else:
|
|
oldValue = None
|
|
|
|
if oldValue == value: return # no change
|
|
|
|
session.history.register(lambda v=oldValue: set_quarterNotesPerMinute(v), descriptionString="Tempo")
|
|
|
|
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 = Each Group produces a Quarter """
|
|
if session.data.whatTypeOfUnit == ticks: return #no change
|
|
|
|
session.history.register(lambda v=session.data.whatTypeOfUnit: set_whatTypeOfUnit(v), descriptionString="Group Duration")
|
|
session.data.whatTypeOfUnit = ticks
|
|
session.data.buildAllTracks()
|
|
if session.inLoopMode:
|
|
_loopNow()
|
|
updatePlayback()
|
|
callbacks._timeSignatureChanged()
|
|
|
|
def set_howManyUnits(value):
|
|
"""Numerator of Time Signature = Steps per Pattern"""
|
|
if session.data.howManyUnits == value: return #no change
|
|
|
|
session.history.register(lambda v=session.data.howManyUnits: set_howManyUnits(v), descriptionString="Steps per Pattern")
|
|
session.data.howManyUnits = value
|
|
session.data.buildAllTracks()
|
|
if session.inLoopMode:
|
|
_loopNow()
|
|
updatePlayback()
|
|
callbacks._timeSignatureChanged()
|
|
|
|
|
|
def set_subdivisions(value):
|
|
"""In groups of 1, 2, 4"""
|
|
if session.data.subdivisions == value: return #no change
|
|
session.history.register(lambda v=session.data.subdivisions: set_subdivisions(v), descriptionString="Group Size")
|
|
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"""
|
|
oldValue = session.data.subdivisions
|
|
result = session.data.convertSubdivisions(value, errorHandling)
|
|
if result: #bool for success
|
|
session.history.register(lambda v=oldValue: convert_subdivisions(v, "delete"), descriptionString="Convert Grouping") #the error handling = delete should not matter at all. We are always in a position where this is possible because we just converted to the current state from a valid one.
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
callbacks._timeSignatureChanged() #includes pattern changed for all tracks and 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.history.register(lambda v=session.data.swing: set_swing(v), descriptionString="Swing")
|
|
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.
|
|
|
|
That said, the GUI and the apcMini only go from 0 to 100, the negative range is ignored.
|
|
|
|
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]) #handles undo and callbacks
|
|
|
|
|
|
def set_numberOfMeasures(value):
|
|
if session.data.numberOfMeasures == value:
|
|
return
|
|
|
|
session.history.register(lambda v=session.data.numberOfMeasures: set_numberOfMeasures(v), descriptionString="Measures per Track")
|
|
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.history.register(lambda v=session.data.measuresPerGroup: set_measuresPerGroup(v), descriptionString="Measures per Group")
|
|
session.data.measuresPerGroup = value
|
|
#No playback change
|
|
callbacks._scoreChanged()
|
|
|
|
def changeTrackName(trackId, name):
|
|
"""The template gurantees a unique, sanitized name across tracks and groups"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
if not name.lower() in (gr.lower() for gr in getGroups()):
|
|
session.history.register(lambda trId=trackId, v=track.sequencerInterface.name: changeTrackName(trId,v), descriptionString="Track Name")
|
|
track.sequencerInterface.name = name #sanitizes on its own. Checks for duplicate tracks but not groups
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def changeTrackColor(trackId, colorInHex):
|
|
"""Expects "#rrggbb"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
assert len(colorInHex) == 7, colorInHex
|
|
session.history.register(lambda trId=trackId, v=track.color: changeTrackColor(trId,v), descriptionString="Track Color")
|
|
track.color = colorInHex
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def changeTrackMidiChannel(trackId, newChannel:int):
|
|
"""newChannel is 1-16, we convert here to internal format 0-15.
|
|
Callbacks export data sends 1-16 again"""
|
|
if newChannel < 1 or newChannel > 16:
|
|
logger.warning(f"Midi Channel must be between 1-16 for this function, was: {newChannel}. Doing nothing.")
|
|
return
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.midiChannel: changeTrackMidiChannel(trId,v), descriptionString="Track Midi Channel")
|
|
track.midiChannel = newChannel-1
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackMetaDataChanged(track)
|
|
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
|
|
|
|
def changeTrackStepDelayWrapAround(trackId, newState:bool):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.stepDelayWrapAround: changeTrackStepDelayWrapAround(trId,v), descriptionString="Track Step Delay Wrap-Around")
|
|
track.stepDelayWrapAround = newState
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackMetaDataChanged(track)
|
|
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
|
|
|
|
def changeTrackRepeatDiminishedPatternInItself(trackId, newState:bool):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.repeatDiminishedPatternInItself: changeTrackRepeatDiminishedPatternInItself(trId,v), descriptionString="Track Repeat Diminished Pattern in itself")
|
|
track.repeatDiminishedPatternInItself = newState
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackMetaDataChanged(track)
|
|
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
|
|
|
|
def changeCurrentTrack(trackId):
|
|
"""This is for communication between the GUI and APCmini controller. The engine has no concept
|
|
of a current track."""
|
|
track = session.data.trackById(trackId)
|
|
assert track, trackId
|
|
callbacks._currentTrackChanged(track)
|
|
|
|
def currentTrackBy(currentTrackId, value:int):
|
|
"""Convenience for the apcMiniController or a GUI that wants shortcuts.
|
|
Only use +1 and -1 for value for stepping.
|
|
We do NOT test for other values if the overshoot the session.data.tracks index!!
|
|
We need to know what the current track is because the engine doesn't know it.
|
|
|
|
Ignores invisible tracks, aka tracks in a GUI-folded group.
|
|
"""
|
|
|
|
assert value in (-1, 1), value
|
|
|
|
currentTrack = session.data.trackById(currentTrackId)
|
|
assert currentTrack, currentTrackId
|
|
onlyVisibleTracks = [track for track in session.data.tracks if track.visible]
|
|
if not onlyVisibleTracks: return; #all tracks are hidden
|
|
|
|
|
|
if not currentTrack in onlyVisibleTracks:
|
|
changeCurrentTrack(id(onlyVisibleTracks[0]))
|
|
return #an impossible situation.
|
|
|
|
currentIndex = onlyVisibleTracks.index(currentTrack)
|
|
|
|
if value == -1 and currentIndex == 0: return #already first track
|
|
elif value == 1 and len(onlyVisibleTracks) == currentIndex+1: return #already last track
|
|
|
|
newCurrentTrack = onlyVisibleTracks[currentIndex+value]
|
|
changeCurrentTrack(id(newCurrentTrack))
|
|
|
|
def addTrack(scale=None):
|
|
if scale:
|
|
assert type(scale) == tuple
|
|
track = session.data.addTrack(scale=scale)
|
|
assert track
|
|
trackId = id(track)
|
|
session.history.register(lambda trId=trackId: deleteTrack(trId), descriptionString="Add Track")
|
|
session.data.sortTracks() #in place sorting for groups
|
|
callbacks._numberOfTracksChanged()
|
|
return trackId
|
|
|
|
def createSiblingTrack(trackId): #aka clone track
|
|
"""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)
|
|
if not track: return
|
|
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
|
|
newTrack.midiChannel = track.midiChannel
|
|
|
|
if track.group:
|
|
session.data.setGroup(newTrack, track.group) #includes session.data.buildAllTracks()
|
|
else:
|
|
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)
|
|
session.history.register(lambda trId=id(newTrackAgain): deleteTrack(trId), descriptionString="Clone Track")
|
|
session.data.sortTracks() #in place sorting for groups
|
|
callbacks._numberOfTracksChanged()
|
|
return newTrack.export()
|
|
|
|
def _reinsertDeletedTrack(track, trackIndex):
|
|
"""For undo"""
|
|
track.sequencerInterface.recreateThroughUndo()
|
|
session.data.tracks.insert(trackIndex, track)
|
|
session.history.register(lambda trId=id(track): deleteTrack(trId), descriptionString="Add deleted Track again")
|
|
updatePlayback()
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
def deleteTrack(trackId):
|
|
""" indirectly calls session.data.buildAllTracks() through group change.
|
|
This is wasteful, but it acceptable. We let the code stay simple in exchange for redundant
|
|
re-building of all tracks.
|
|
"""
|
|
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
oldIndex = session.data.tracks.index(track)
|
|
|
|
with session.history.sequence("Delete Track"):
|
|
setTrackGroup(trackId, "") #has it's own undo
|
|
deletedTrack = session.data.deleteTrack(track)
|
|
|
|
if not session.data.tracks: #always keep at least one track
|
|
addTrack() #has it's own undo
|
|
session.history.register(lambda tr=deletedTrack, pos=oldIndex: _reinsertDeletedTrack(tr, pos), descriptionString="Delete Track")
|
|
else:
|
|
session.history.register(lambda tr=deletedTrack, pos=oldIndex: _reinsertDeletedTrack(tr, pos), descriptionString="Delete Track")
|
|
|
|
updatePlayback()
|
|
|
|
callbacks._numberOfTracksChanged() #TODO: throws a console error "port not found". but that is not critical.
|
|
|
|
|
|
def moveTrack(trackId, newIndex):
|
|
"""index is 0 based.
|
|
With groups involved free movevement is not allowed anymore.
|
|
All tracks of a group have to be next to each other and have to be in that exact place
|
|
in session.data.tracks , so that jack metadata port order works, groups or not.
|
|
"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
oldIndex = session.data.tracks.index(track)
|
|
if not oldIndex == newIndex:
|
|
session.history.register(lambda tr=trackId, pos=oldIndex: moveTrack(trackId, pos), descriptionString="Move Track")
|
|
session.data.tracks.pop(oldIndex)
|
|
session.data.tracks.insert(newIndex, track)
|
|
session.data.sortTracks() #in place sorting for groups
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
|
|
def setTrackPatternLengthMultiplicator(trackId, newMultiplicator:int):
|
|
if newMultiplicator < 1 or not isinstance(newMultiplicator, int):
|
|
return #Invalid input
|
|
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda tr=trackId, v=track.patternLengthMultiplicator: setTrackPatternLengthMultiplicator(trackId, v), descriptionString="Pattern Multiplier")
|
|
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 Groups
|
|
#Groups are dynamic. What groups exists and in which order is derived from the tracks themselves
|
|
|
|
def getGroups():
|
|
"""
|
|
Returns an iterator of strings in order of the tracks.
|
|
Will return only existing groups, that contain at least one track"""
|
|
return session.data.groups.keys()
|
|
|
|
def setTrackGroup(trackId, groupName:str):
|
|
"""A not yet existing groupName will create that.
|
|
Set to empty string to create a standalone track"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
groupName = ''.join(ch for ch in groupName if ch.isalnum()) #sanitize
|
|
groupName = " ".join(groupName.split()) #remove double spaces
|
|
if not track.group == groupName:
|
|
if not groupName.lower() in (track.sequencerInterface.name.lower() for track in session.data.tracks):
|
|
session.history.register(lambda tr=trackId, v=track.group: setTrackGroup(trackId, v), descriptionString="Track Group")
|
|
session.data.setGroup(track, groupName) #includes session.data.buildAllTracks(). This is wasteful, but it acceptable. We let the code stay simple in exchange for redundant re-building of all tracks.
|
|
updatePlayback()
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
def moveGroup(groupName:str, newIndex:int):
|
|
""""
|
|
index is 0 based.
|
|
newIndex is like a track index. But instead of moving a single track we move all tracks
|
|
of one group to this position"""
|
|
|
|
#find tracks with that group.
|
|
#We assume they are all next to each other, because that is how session.data auto-sorts tracks
|
|
groupMembers = [track for track in session.data.tracks if track.group == groupName]
|
|
firstGroupTrack = groupMembers[0]
|
|
firstGroupTrackIndex = session.data.tracks.index(firstGroupTrack)
|
|
|
|
if firstGroupTrackIndex == newIndex:
|
|
return
|
|
|
|
session.history.register(lambda gr=groupName, pos=firstGroupTrackIndex: moveGroup(gr, pos), descriptionString="Move Group")
|
|
for offset, track in enumerate(groupMembers):
|
|
#We can't check and assert indices here because the list changes under our nose.
|
|
#assert firstGroupTrackIndex + offset == session.data.tracks.index(track), (firstGroupTrackIndex, offset, session.data.tracks.index(track), track.sequencerInterface.name)
|
|
#popTr = session.data.tracks.pop(firstGroupTrackIndex + offset)
|
|
popTr = session.data.tracks.pop(session.data.tracks.index(track))
|
|
#assert track is popTr, (track, track.sequencerInterface.name, popTr, popTr.sequencerInterface.name )
|
|
session.data.tracks.insert(newIndex+offset, track)
|
|
session.data.sortTracks() #in place sorting for groups
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
|
|
def setGroupVisible(groupName:str, force:bool=None):
|
|
"""A convenience function for the gui. just a flag that gets saved and loaded and changes
|
|
are reported via callback
|
|
|
|
Hides all tracks belonging to that track in reality. But we offer no way to hide a non-group
|
|
track.
|
|
|
|
Calling without the force parameter to True/False toggles visibility.
|
|
"""
|
|
groupMembers = [track for track in session.data.tracks if track.group == groupName]
|
|
for track in groupMembers:
|
|
if track.group == groupName:
|
|
if not force is None:
|
|
track.visible = bool(force)
|
|
else:
|
|
track.visible = not track.visible
|
|
#no need to update playback
|
|
session.data.sortTracks() #in place sorting for groups
|
|
callbacks._numberOfTracksChanged()
|
|
|
|
|
|
|
|
|
|
#Track Switches
|
|
#Aka measures
|
|
def _setTrackStructure(trackId, structure, undoMessage):
|
|
"""For undo. Used by all functions as entry point, then calls itself for undo/redo.
|
|
structure is a set of integers which we can copy with .copy()"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
session.history.register(lambda tr=trackId, v=track.structure.copy(), msg=undoMessage: _setTrackStructure(trackId, v, msg), descriptionString=undoMessage)
|
|
|
|
track.structure = structure #restore data
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
|
|
def _removeMeasureModificationsWithUndo(trackId, position):
|
|
"""Must be run in a history sequence context manager!"""
|
|
track = session.data.trackById(trackId)
|
|
if position in track.whichPatternsAreScaleTransposed:
|
|
setSwitchScaleTranspose(trackId, position, 0) #set to default value. track export will remove the value from the data
|
|
if position in track.whichPatternsAreHalftoneTransposed:
|
|
setSwitchHalftoneTranspose(trackId, position, 0) #set to default value. track export will remove the value from the data
|
|
if position in track.whichPatternsAreStepDelayed:
|
|
setSwitchStepDelay(trackId, position, 0) #set to default value. track export will remove the value from the data
|
|
if position in track.whichPatternsHaveAugmentationFactor:
|
|
setSwitchAugmentationsFactor(trackId, position, 1.0) #set to default value. track export will remove the value from the data
|
|
|
|
|
|
def setSwitches(trackId, setOfPositions, newBool):
|
|
"""Used in the GUI to select multiple switches in a row by dragging the mouse"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
with session.history.sequence("Set Measures"):
|
|
session.history.register(lambda tr=trackId, v=track.structure.copy(): _setTrackStructure(trackId, v, "Set Measures"), descriptionString="Set Measures")
|
|
|
|
if newBool:
|
|
track.structure = track.structure.union(setOfPositions) #merge: add setOfPositions to the existing one
|
|
else:
|
|
track.structure = track.structure.difference(setOfPositions) #replace: remove everything from setOfPositions that is in the existing one, ignore entries from setOfPositions not in existing.
|
|
|
|
for position in setOfPositions:
|
|
_removeMeasureModificationsWithUndo(trackId, position)
|
|
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def setSwitch(trackId, position, newBool):
|
|
"""e.g. for GUI Single click operations. Switch on and off a measure"""
|
|
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
with session.history.sequence("Set Measures"):
|
|
session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Set Measures"), descriptionString="Set Measures")
|
|
|
|
if newBool:
|
|
if position in track.structure: return
|
|
track.structure.add(position)
|
|
else:
|
|
if not position in track.structure: return
|
|
track.structure.remove(position)
|
|
|
|
_removeMeasureModificationsWithUndo(trackId, position)
|
|
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
def trackInvertSwitches(trackId):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Invert Measures"), descriptionString="Invert Measures")
|
|
"""
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Track Measures Off"), descriptionString="Track Measures Off")
|
|
track.structure = set()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def trackOnAllSwitches(trackId):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Track Measures On"), descriptionString="Track Measures On")
|
|
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)
|
|
session.history.register(lambda trId=id(targetTrack), v=targetTrack.structure.copy(): _setTrackStructure(trId, v, "Copy Measures"), descriptionString="Copy Measures")
|
|
targetTrack.structure = targetTrack.structure.union(sourceTrack.structure)
|
|
targetTrack.whichPatternsAreScaleTransposed.update(sourceTrack.whichPatternsAreScaleTransposed)
|
|
targetTrack.whichPatternsAreHalftoneTransposed.update(sourceTrack.whichPatternsAreHalftoneTransposed)
|
|
targetTrack.whichPatternsAreStepDelayed.update(sourceTrack.whichPatternsAreStepDelayed)
|
|
targetTrack.whichPatternsHaveAugmentationFactor.update(sourceTrack.whichPatternsHaveAugmentationFactor)
|
|
targetTrack.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(targetTrack)
|
|
|
|
def trackPatternReplaceFrom(sourceTrackId, targetTrackId):
|
|
if not sourceTrackId == targetTrackId:
|
|
sourceTrack = session.data.trackById(sourceTrackId)
|
|
targetTrack = session.data.trackById(targetTrackId)
|
|
session.history.register(lambda trId=id(targetTrack), v=targetTrack.structure.copy(): _setTrackStructure(trId, v, "Replace Measures"), descriptionString="Replace Measures")
|
|
|
|
copyPattern = sourceTrack.pattern.copy(newParentTrack = targetTrack)
|
|
targetTrack.pattern = copyPattern
|
|
|
|
targetTrack.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(targetTrack)
|
|
|
|
#Transpositions and Modal Shifts
|
|
#StepDelay and AugmentationFactor
|
|
|
|
def _setSwitchesScaleTranspose(trackId, whichPatternsAreScaleTransposed):
|
|
"""For undo. Used by all functions as entry point, then calls itself for undo/redo.
|
|
whichPatternsAreScaleTransposed is a dicts of int:int which we can copy with .copy()"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsAreScaleTransposed.copy(): _setSwitchesScaleTranspose(trackId, v), descriptionString="Set Modal Shift")
|
|
|
|
track.whichPatternsAreScaleTransposed = whichPatternsAreScaleTransposed #restore data
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
|
|
def setSwitchScaleTranspose(trackId, position:int, transpose:int):
|
|
"""Scale transposition is flipped. lower value means higher pitch.
|
|
Default value is 0."""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsAreScaleTransposed.copy(): _setSwitchesScaleTranspose(trackId, v), descriptionString="Set Modal Shift")
|
|
track.whichPatternsAreScaleTransposed[position] = transpose
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
|
|
def _setSwitchHalftoneTranspose(trackId, whichPatternsAreHalftoneTransposed):
|
|
"""For undo. Used by all functions as entry point, then calls itself for undo/redo.
|
|
whichPatternsAreScaleTransposed is a dicts of int:int which we can copy with .copy()"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsAreHalftoneTransposed.copy(): _setSwitchHalftoneTranspose(trackId, v), descriptionString="Set Half Tone Shift")
|
|
|
|
track.whichPatternsAreHalftoneTransposed = whichPatternsAreHalftoneTransposed #restore data
|
|
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
|
|
def setSwitchHalftoneTranspose(trackId, position:int, transpose:int):
|
|
"""Halftone transposition is not flipped. Higher value means higher pitch
|
|
Default value is 0.
|
|
"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsAreHalftoneTransposed.copy(): _setSwitchHalftoneTranspose(trackId, v), descriptionString="Set Half Tone Shift")
|
|
track.whichPatternsAreHalftoneTransposed[position] = transpose
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
def _setSwitchStepDelay(trackId, whichPatternsAreStepDelayed):
|
|
"""For undo. Used by all functions as entry point, then calls itself for undo/redo.
|
|
whichPatternsAreStepDelayed is a dicts of int:int which we can copy with .copy()"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsAreStepDelayed.copy(): _setSwitchStepDelay(trackId, v), descriptionString="Set Step Delay")
|
|
|
|
track.whichPatternsAreStepDelayed = whichPatternsAreStepDelayed #restore data
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def setSwitchStepDelay(trackId, position:int, delay:int):
|
|
"""Public entry function for _setSwitchStepDelay.
|
|
Default value is 0"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsAreStepDelayed.copy(): _setSwitchStepDelay(trackId, v), descriptionString="Set Step Delay")
|
|
track.whichPatternsAreStepDelayed[position] = delay
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
|
|
def _setSwitchAugmentationsFactor(trackId, whichPatternsHaveAugmentationFactor):
|
|
"""For undo. Used by all functions as entry point, then calls itself for undo/redo.
|
|
whichPatternsHaveAugmentationFactor is a dicts of int:float which we can copy with .copy()"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsHaveAugmentationFactor.copy(): _setSwitchAugmentationsFactor(trackId, v), descriptionString="Set Augmentation Factor")
|
|
|
|
track.whichPatternsHaveAugmentationFactor = whichPatternsHaveAugmentationFactor #restore data
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
def setSwitchAugmentationsFactor(trackId, position:int, factor:float):
|
|
"""Public entry function for _setSwitchAugmentationsFactor.
|
|
Default value is 1.0"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda tr=trackId, v=track.whichPatternsHaveAugmentationFactor.copy(): _setSwitchAugmentationsFactor(trackId, v), descriptionString="Set Augmentation Factor")
|
|
track.whichPatternsHaveAugmentationFactor[position] = factor
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._trackStructureChanged(track)
|
|
return True
|
|
|
|
|
|
def _registerHistoryWholeTrackSwitches(track):
|
|
"""This is used by insertSilence, clearSwitchGroupModifications,
|
|
exchangeSwitchGroupWithGroupToTheRight etc.
|
|
|
|
It assumes that it runs inside this context:
|
|
with session.history.sequence("asdasd"):
|
|
"""
|
|
trackId = id(track)
|
|
session.history.register(lambda trId=trackId, v=track.patternLengthMultiplicator: setTrackPatternLengthMultiplicator(trId, v), descriptionString="Pattern Multiplier")
|
|
session.history.register(lambda trId=trackId, v=track.structure.copy(): _setTrackStructure(trId, v, "Change Group"), descriptionString="Change Group")
|
|
session.history.register(lambda trId=trackId, v=track.whichPatternsAreScaleTransposed.copy(): _setSwitchesScaleTranspose(trId, v), descriptionString="Set Modal Shift")
|
|
session.history.register(lambda trId=trackId, v=track.whichPatternsAreHalftoneTransposed.copy(): _setSwitchHalftoneTranspose(trId, v), descriptionString="Set Half Tone Shift")
|
|
session.history.register(lambda trId=trackId, v=track.whichPatternsAreStepDelayed.copy(): _setSwitchStepDelay(trId, v), descriptionString="Set Step Delay")
|
|
session.history.register(lambda trId=trackId, v=track.whichPatternsHaveAugmentationFactor.copy(): _setSwitchAugmentationsFactor(trId, v), descriptionString="Set Augmentation Factor")
|
|
|
|
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.
|
|
with session.history.sequence("Insert/Duplicate Group"): #this actually handles duplicateSwitchGroup undo as well!!!
|
|
for track in session.data.tracks:
|
|
_registerHistoryWholeTrackSwitches(track)
|
|
|
|
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() }
|
|
track.whichPatternsAreStepDelayed = { (k+thisTrackHowMany if k >= thisTrackWhere else k):v for k,v in track.whichPatternsAreStepDelayed.items() }
|
|
track.whichPatternsHaveAugmentationFactor = { (k+thisTrackHowMany if k >= thisTrackWhere else k):v for k,v in track.whichPatternsHaveAugmentationFactor.items() }
|
|
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
callbacks._dataChanged() #register undo
|
|
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
|
|
|
|
#Undo: InsertSilence has a complete undo already registered. We chose a neutral undo description so it handles both duplicate and insert silence.
|
|
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]
|
|
if switch-thisGroupSize in track.whichPatternsAreStepDelayed:
|
|
track.whichPatternsAreStepDelayed[switch] = track.whichPatternsAreStepDelayed[switch-thisGroupSize]
|
|
if switch-thisGroupSize in track.whichPatternsHaveAugmentationFactor:
|
|
track.whichPatternsHaveAugmentationFactor[switch] = track.whichPatternsHaveAugmentationFactor[switch-thisGroupSize]
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
|
|
def exchangeSwitchGroupWithGroupToTheRight(startMeasureForGroup:int, endMeasureExclusive:int):
|
|
"""The group is defined by the given measure range. The group right of it has the same dimensions.
|
|
In a GUI you can use that to move groups left and right. We only supply the "switch with right"
|
|
variant (and not left) because that is easier to comprehend.
|
|
"""
|
|
|
|
with session.history.sequence("Exchange Group Order"):
|
|
for track in session.data.tracks:
|
|
_registerHistoryWholeTrackSwitches(track)
|
|
|
|
thisTrackStartMeasure = startMeasureForGroup // track.patternLengthMultiplicator #integer division
|
|
thisTrackEndMeasure = endMeasureExclusive // track.patternLengthMultiplicator
|
|
groupSize = thisTrackEndMeasure - thisTrackStartMeasure
|
|
|
|
assert thisTrackStartMeasure + groupSize == thisTrackEndMeasure, (thisTrackStartMeasure, groupSize, thisTrackEndMeasure)
|
|
|
|
tempStructure = set() #integers
|
|
tempScaleTransposed = dict() #position:integers
|
|
tempHalfToneTransposed = dict() #position:integers
|
|
tempStepDelayed = dict() #position:integers
|
|
tempAugmentedFactor = dict() #position:floats
|
|
|
|
#Remember for later testing
|
|
lenStructure = len(track.structure)
|
|
lenHalfToneTransposed = len(track.whichPatternsAreHalftoneTransposed.keys())
|
|
lenScaleTransposed = len(track.whichPatternsAreScaleTransposed.keys())
|
|
lenStepDelayed = len(track.whichPatternsAreStepDelayed.keys())
|
|
lenAugmentedFactor = len(track.whichPatternsHaveAugmentationFactor.keys())
|
|
|
|
#First move right group into a temporary buffer to have it out of the way
|
|
for switch in range(thisTrackStartMeasure+groupSize, thisTrackEndMeasure+groupSize): #switch is a number
|
|
if switch in track.structure:
|
|
tempStructure.add(switch)
|
|
track.structure.remove(switch)
|
|
if switch in track.whichPatternsAreScaleTransposed:
|
|
tempScaleTransposed[switch] = track.whichPatternsAreScaleTransposed[switch]
|
|
del track.whichPatternsAreScaleTransposed[switch]
|
|
if switch in track.whichPatternsAreHalftoneTransposed:
|
|
tempHalfToneTransposed[switch] = track.whichPatternsAreHalftoneTransposed[switch]
|
|
del track.whichPatternsAreHalftoneTransposed[switch]
|
|
if switch in track.whichPatternsAreStepDelayed:
|
|
tempStepDelayed[switch] = track.whichPatternsAreStepDelayed[switch]
|
|
del track.whichPatternsAreStepDelayed[switch]
|
|
if switch in track.whichPatternsHaveAugmentationFactor:
|
|
tempAugmentedFactor[switch] = track.whichPatternsHaveAugmentationFactor[switch]
|
|
del track.whichPatternsHaveAugmentationFactor[switch]
|
|
|
|
#Now move current group to the right, which is now empty.
|
|
for switch in range(thisTrackStartMeasure, thisTrackEndMeasure): #switch is a number
|
|
if switch in track.structure:
|
|
track.structure.add(switch + groupSize)
|
|
track.structure.remove(switch)
|
|
if switch in track.whichPatternsAreScaleTransposed:
|
|
track.whichPatternsAreScaleTransposed[switch+groupSize] = track.whichPatternsAreScaleTransposed[switch]
|
|
del track.whichPatternsAreScaleTransposed[switch]
|
|
if switch in track.whichPatternsAreHalftoneTransposed:
|
|
track.whichPatternsAreHalftoneTransposed[switch+groupSize] = track.whichPatternsAreHalftoneTransposed[switch]
|
|
del track.whichPatternsAreHalftoneTransposed[switch]
|
|
if switch in track.whichPatternsAreStepDelayed:
|
|
track.whichPatternsAreStepDelayed[switch+groupSize] = track.whichPatternsAreStepDelayed[switch]
|
|
del track.whichPatternsAreStepDelayed[switch]
|
|
if switch in track.whichPatternsHaveAugmentationFactor:
|
|
track.whichPatternsHaveAugmentationFactor[switch+groupSize] = track.whichPatternsHaveAugmentationFactor[switch]
|
|
del track.whichPatternsHaveAugmentationFactor[switch]
|
|
|
|
|
|
|
|
#Move old right-group into its new place
|
|
for sw in tempStructure:
|
|
track.structure.add(sw-groupSize)
|
|
for stPos, stVal in tempScaleTransposed.items():
|
|
track.whichPatternsAreScaleTransposed[stPos-groupSize] = stVal
|
|
for htPos, htVal in tempHalfToneTransposed.items():
|
|
track.whichPatternsAreHalftoneTransposed[htPos-groupSize] = htVal
|
|
for sDPos, sDVal in tempStepDelayed.items():
|
|
track.whichPatternsAreStepDelayed[sDPos-groupSize] = sDVal
|
|
for aFPos, aFVal in tempAugmentedFactor.items():
|
|
track.whichPatternsHaveAugmentationFactor[aFPos-groupSize] = aFVal
|
|
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
#Do some tests
|
|
assert lenStructure == len(track.structure), (lenStructure, len(track.structure))
|
|
assert lenScaleTransposed == len(track.whichPatternsAreScaleTransposed.keys()), (lenScaleTransposed, len(track.whichPatternsAreScaleTransposed.keys()))
|
|
assert lenHalfToneTransposed == len(track.whichPatternsAreHalftoneTransposed.keys()), (lenHalfToneTransposed, len(track.whichPatternsAreHalftoneTransposed.keys()))
|
|
assert lenStepDelayed == len(track.whichPatternsAreStepDelayed.keys()), (lenStepDelayed, len(track.whichPatternsAreStepDelayed.keys()))
|
|
assert lenAugmentedFactor == len(track.whichPatternsHaveAugmentationFactor.keys()), (lenAugmentedFactor, len(track.whichPatternsHaveAugmentationFactor.keys()))
|
|
|
|
callbacks._dataChanged() #register undo
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
def clearSwitchGroupModifications(startMeasureForGroup:int, endMeasureExclusive:int):
|
|
"""startMeasureForGroup and endMeasureExclusive are in the global, un-multiplied measure counting
|
|
format."""
|
|
with session.history.sequence("Clear all Group Transpositions"):
|
|
for track in session.data.tracks:
|
|
_registerHistoryWholeTrackSwitches(track)
|
|
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]
|
|
if switch in track.whichPatternsAreStepDelayed:
|
|
del track.whichPatternsAreStepDelayed[switch]
|
|
if switch in track.whichPatternsHaveAugmentationFactor:
|
|
del track.whichPatternsHaveAugmentationFactor[switch]
|
|
callbacks._trackStructureChanged(track)
|
|
callbacks._dataChanged() #register undo
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
def deleteSwitches(howMany, fromMeasureNumber):
|
|
"""Parameters are un-multiplied measures."""
|
|
|
|
with session.history.sequence("Delete whole Group"):
|
|
for track in session.data.tracks:
|
|
_registerHistoryWholeTrackSwitches(track)
|
|
|
|
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
|
|
|
|
new_stepDelayed = dict()
|
|
for k,v in track.whichPatternsAreStepDelayed.items():
|
|
if k < thisTrackWhere:
|
|
new_stepDelayed[k] = v
|
|
elif k >= thisTrackWhere+thisTrackHowMany: #like a text editor let gravitate left into the hole left by the deleted range
|
|
new_stepDelayed[k-thisTrackHowMany] = v
|
|
#else: #discard all in range to delete
|
|
track.whichPatternsAreStepDelayed = new_stepDelayed
|
|
|
|
new_augmentFactor = dict()
|
|
for k,v in track.whichPatternsHaveAugmentationFactor.items():
|
|
if k < thisTrackWhere:
|
|
new_augmentFactor[k] = v
|
|
elif k >= thisTrackWhere+thisTrackHowMany: #like a text editor let gravitate left into the hole left by the deleted range
|
|
new_augmentFactor[k-thisTrackHowMany] = v
|
|
#else: #discard all in range to delete
|
|
track.whichPatternsHaveAugmentationFactor = new_augmentFactor
|
|
|
|
|
|
callbacks._trackStructureChanged(track)
|
|
|
|
callbacks._dataChanged() #register undo
|
|
session.data.buildAllTracks()
|
|
updatePlayback()
|
|
|
|
#Pattern Steps
|
|
|
|
def setPattern(trackId, patternList, undoMessage):
|
|
"""Change the whole pattern, send a callback with the whole pattern.
|
|
This is also the main undo/redo function.
|
|
|
|
It is rarely used directly by the GUI, if at all. Normal changes are atomic.
|
|
"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(), msg=undoMessage: setPattern(trId, v, msg), descriptionString=undoMessage)
|
|
|
|
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.
|
|
|
|
format: {'index': 0, 'pitch': 7, 'factor': 1, 'velocity': 90}
|
|
|
|
This is also for velocity!
|
|
|
|
This function checks if the new step is within the limits of the current sounding pattern
|
|
and will prevent changes or additions outside the current limits.
|
|
"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
inRange = stepExportDict["index"] < session.data.howManyUnits and stepExportDict["pitch"] < track.pattern.numberOfSteps
|
|
if not inRange: return
|
|
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Step"), descriptionString="Change Step")
|
|
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. e.g. GUI-Click on an existing step."""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
|
|
inRange = index < session.data.howManyUnits and pitch < track.pattern.numberOfSteps
|
|
if not inRange: return
|
|
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Remove Step"), descriptionString="Remove Step")
|
|
oldNote = track.pattern.stepByIndexAndPitch(index, pitch)
|
|
track.pattern.data.remove(oldNote)
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._removeStep(track, index, pitch, oldNote["factor"])
|
|
|
|
def toggleStep(trackId, index, pitch, factor=1, velocity=None):
|
|
"""Checks the current state of a step and decides if on or off.
|
|
Toggled Notes have average velocity and factor 1. If you need more fine control use setStep
|
|
directly"""
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
maybeNote = track.pattern.stepByIndexAndPitch(index, pitch)
|
|
if maybeNote is None:
|
|
if velocity is None:
|
|
velocity = getAverageVelocity(trackId)
|
|
setStep(trackId, {'index': index, 'pitch': pitch, 'factor': factor, 'velocity': velocity})
|
|
else:
|
|
removeStep(trackId, 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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.scale[:]: setScale(trId, v, callback=True), descriptionString="Set Scale")
|
|
track.pattern.scale = scale #tuple, or list if oversight in json loading :)
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.simpleNoteNames[:]: setSimpleNoteNames(trId, v), descriptionString="Note Names")
|
|
track.pattern.simpleNoteNames = simpleNoteNames #list of strings
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def transposeHalftoneSteps(trackId, steps):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.scale[:]: setScale(trId, v, callback=True), descriptionString="Transpose Scale")
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Invert Steps"), descriptionString="Invert Steps")
|
|
track.pattern.invert()
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternOnAllSteps(trackId):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "All Steps On"), descriptionString="All Steps On")
|
|
track.pattern.fill()
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternOffAllSteps(trackId):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "All Steps Off"), descriptionString="All Steps Off")
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Invert Row"), descriptionString="Invert Row")
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Clear Row"), descriptionString="Clear Row")
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Fill Row with Repeat"), descriptionString="Fill Row with Repeat")
|
|
track.pattern.repeatFromStep(pitchindex, index)
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
updatePlayback()
|
|
callbacks._patternChanged(track)
|
|
|
|
def patternRowChangeVelocity(trackId, pitchindex, delta):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Row Velocity"), descriptionString="Change Row Velocity")
|
|
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)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.scale[:]: setScale(trId, v, callback=True), descriptionString="Set 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()
|
|
updatePlayback()
|
|
callbacks._trackMetaDataChanged(track)
|
|
|
|
def changePatternVelocity(trackId, steps):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Pattern Velocity"), descriptionString="Change Pattern Velocity")
|
|
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)
|
|
if not track: return
|
|
|
|
#We could use setScale for undo. But this requires a different set of callbacks. We use our own function, eventhough some of the calculations are not needed for undo.
|
|
|
|
session.history.register(lambda trId=trackId, v=track.pattern.numberOfSteps: resizePatternWithoutScale(trId, v), descriptionString="Number of Notes in Pattern")
|
|
|
|
currentNr = track.pattern.numberOfSteps #int
|
|
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)
|
|
if not track: return
|
|
try:
|
|
midipitch = track.pattern.scale[row]
|
|
cbox.send_midi_event(0x90+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction)
|
|
except IndexError: #We got a note that is not in the current scale and pattern. E.g. because numberOfSteps is 7 but we received 8.
|
|
pass
|
|
|
|
def noteOff(trackId, row):
|
|
track = session.data.trackById(trackId)
|
|
if not track: return
|
|
try:
|
|
midipitch = track.pattern.scale[row]
|
|
cbox.send_midi_event(0x80+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction)
|
|
except IndexError: #We got a note that is not in the current scale and pattern. E.g. because numberOfSteps is 7 but we received 8.
|
|
pass
|
|
|