#! /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 . """ 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 #index is from 0 and howManyUnits*Multp is from 1 (because it is length), so we just need <, and not <=. inRange = stepExportDict["index"] < session.data.howManyUnits*track.patternLengthMultiplicator 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*track.patternLengthMultiplicator 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