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.

1667 lines
76 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
This application is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
4 years ago
#Standard Library Modules
from typing import List, Set, Dict, Tuple
#Third Party Modules
from template.calfbox import cbox
4 years ago
#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
4 years ago
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__()
4 years ago
self.timeSignatureChanged = []
self.scoreChanged = []
self.numberOfMeasuresChanged = []
4 years ago
self.trackStructureChanged = []
4 years ago
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 = []
4 years ago
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"""
4 years ago
if session.data.tempoMap.isTransportMaster:
export = session.data.tempoMap.getQuarterNotesPerMinute()
4 years ago
else:
export = None
for func in self.quarterNotesPerMinuteChanged:
func(export)
callbacks._dataChanged()
4 years ago
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
4 years ago
status = playbackStatus()
4 years ago
for func in self.setPlaybackTicks:
func(ppqn, status)
4 years ago
def _loopChanged(self, measurenumber, loopStart, loopEnd):
export = measurenumber
for func in self.loopChanged:
func(export)
4 years ago
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)
4 years ago
def _timeSignatureChanged(self):
nr = session.data.howManyUnits
typ = session.data.whatTypeOfUnit
4 years ago
for func in self.timeSignatureChanged:
func(nr, typ)
##All patterns and tracks need updates:
for track in session.data.tracks:
4 years ago
self._patternChanged(track)
self._subdivisionsChanged() #update subdivisions. We do not include them in the score or track export on purpose.
callbacks._dataChanged()
4 years ago
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
4 years ago
for func in self.subdivisionsChanged:
func(export)
callbacks._dataChanged()
4 years ago
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()
4 years ago
for func in self.scoreChanged:
func(export)
callbacks._dataChanged()
4 years ago
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)
4 years ago
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()
4 years ago
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()
4 years ago
def _removeStep(self, track, index, pitch, factor):
4 years ago
"""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()
4 years ago
4 years ago
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()
4 years ago
4 years ago
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()
4 years ago
for func in self.trackMetaDataChanged:
func(export)
callbacks._dataChanged()
4 years ago
def _numberOfMeasuresChanged(self):
"""The whole song got longer or shorter.
Number of measures is the same for all tracks."""
export = session.data.export()
4 years ago
for func in self.numberOfMeasuresChanged:
func(export)
callbacks._dataChanged()
4 years ago
def _patternLengthMultiplicatorChanged(self, track):
export = track.export()
for func in self.patternLengthMultiplicatorChanged:
func(export)
self._patternChanged(track) #includes dataChanged
self._subdivisionsChanged()
2 years ago
callbacks._dataChanged()
4 years ago
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])
2 years ago
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
4 years ago
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.
4 years ago
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.
4 years ago
#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()
4 years ago
for track in session.data.tracks:
4 years ago
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
4 years ago
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)
4 years ago
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)
4 years ago
def toggleLoop():
"""Plays the current measure as loop.
4 years ago
Current measure is where the playback cursor is
session.inLoopMode is a tuple (start, end)
"""
4 years ago
if session.inLoopMode:
_loopOff()
4 years ago
else:
_loopNow()
4 years ago
def rewind():
"""template.toStart, but removes our loop"""
_loopOff()
toStart()
4 years ago
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)
4 years ago
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()
4 years ago
##Score
def set_quarterNotesPerMinute(value):
2 years ago
"""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")
4 years ago
if value is None:
4 years ago
session.data.tempoMap.isTransportMaster = False #triggers rebuild
4 years ago
elif value == "on":
4 years ago
assert not session.data.tempoMap.isTransportMaster
#keep old bpm value. 120 bpm is default.
session.data.tempoMap.isTransportMaster = True #triggers rebuild
4 years ago
else:
assert value > 0
4 years ago
session.data.tempoMap.setQuarterNotesPerMinute(value)
session.data.tempoMap.isTransportMaster = True #triggers rebuild
4 years ago
#Does not need track rebuilding
4 years ago
updatePlayback()
callbacks._quarterNotesPerMinuteChanged()
4 years ago
def set_whatTypeOfUnit(ticks):
2 years ago
"""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()
4 years ago
updatePlayback()
callbacks._timeSignatureChanged()
4 years ago
def set_howManyUnits(value):
2 years ago
"""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()
4 years ago
updatePlayback()
callbacks._timeSignatureChanged()
4 years ago
def set_subdivisions(value):
2 years ago
"""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()
4 years ago
updatePlayback()
callbacks._subdivisionsChanged()
4 years ago
def convert_subdivisions(value, errorHandling):
""""errorHandling can be fail, delete or merge"""
2 years ago
oldValue = session.data.subdivisions
result = session.data.convertSubdivisions(value, errorHandling)
2 years ago
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()
4 years ago
updatePlayback()
callbacks._timeSignatureChanged() #includes pattern changed for all tracks and subdivisions
#for tr in session.data.tracks:
# callbacks._patternChanged(tr)
4 years ago
else:
callbacks._subdivisionsChanged() #to reset the GUI value back to the working one.
if session.inLoopMode:
_loopNow()
4 years ago
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
2 years ago
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
2 years ago
set_swing(_percentToSwing_Table[value]) #handles undo and callbacks
4 years ago
def set_numberOfMeasures(value):
if session.data.numberOfMeasures == value:
return
2 years ago
session.history.register(lambda v=session.data.numberOfMeasures: set_numberOfMeasures(v), descriptionString="Measures per Track")
session.data.numberOfMeasures = value
session.data.buildSongDuration()
4 years ago
updatePlayback()
callbacks._numberOfMeasuresChanged()
callbacks._scoreChanged() #redundant but cheap and convenient
4 years ago
def set_measuresPerGroup(value):
if session.data.measuresPerGroup == value:
return
2 years ago
session.history.register(lambda v=session.data.measuresPerGroup: set_measuresPerGroup(v), descriptionString="Measures per Group")
session.data.measuresPerGroup = value
4 years ago
#No playback change
callbacks._scoreChanged()
4 years ago
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)
4 years ago
def changeTrackColor(trackId, colorInHex):
"""Expects "#rrggbb"""
track = session.data.trackById(trackId)
if not track: return
4 years ago
assert len(colorInHex) == 7, colorInHex
2 years ago
session.history.register(lambda trId=trackId, v=track.color: changeTrackColor(trId,v), descriptionString="Track Color")
4 years ago
track.color = colorInHex
callbacks._trackMetaDataChanged(track)
4 years ago
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
2 years ago
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))
4 years ago
def addTrack(scale=None):
if scale:
assert type(scale) == tuple
2 years ago
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
4 years ago
def createSiblingTrack(trackId): #aka clone track
4 years ago
"""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
4 years ago
assert type(track.pattern.scale) == tuple
4 years ago
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"
4 years ago
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)
4 years ago
#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)
4 years ago
assert newTrackAgain is newTrack
session.data.tracks.insert(oldIndex+1, newTrackAgain)
2 years ago
session.history.register(lambda trId=id(newTrackAgain): deleteTrack(trId), descriptionString="Clone Track")
session.data.sortTracks() #in place sorting for groups
callbacks._numberOfTracksChanged()
4 years ago
return newTrack.export()
2 years ago
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()
4 years ago
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
2 years ago
oldIndex = session.data.tracks.index(track)
with session.history.sequence("Delete Track"):
setTrackGroup(trackId, "") #has it's own undo
deletedTrack = session.data.deleteTrack(track)
2 years ago
if not session.data.tracks: #always keep at least one track
2 years ago
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")
4 years ago
updatePlayback()
callbacks._numberOfTracksChanged() #TODO: throws a console error "port not found". but that is not critical.
4 years ago
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