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.
 
 

983 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 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)
track.midiChannel = newChannel-1
track.pattern.buildExportCache()
track.buildTrack()
updatePlayback()
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
newTrack.midiChannel = track.midiChannel
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+track.midiChannel, 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+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.sequencerInterface.cboxMidiOutUuid)