#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022 , Nils Hilbricht , Germany ( https : / / www . hilbricht . net )
This file is part of the Laborejo Software Suite ( https : / / www . laborejo . org ) ,
This application is free software : you can redistribute it and / or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation , either version 3 of the License , or
( at your option ) any later version .
This program is distributed in the hope that it will be useful ,
but WITHOUT ANY WARRANTY ; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
GNU General Public License for more details .
You should have received a copy of the GNU General Public License
along with this program . If not , see < http : / / www . gnu . org / licenses / > .
"""
import logging ; logger = logging . getLogger ( __name__ ) ; logger . info ( " import " )
#Standard Library Modules
from typing import List , Set , Dict , Tuple
#Third Party Modules
from template . calfbox import cbox
#Template Modules
import template . engine . api #we need direct access to the module to inject data in the provided structures. but we also need the functions directly. next line:
from template . engine . api import *
from template . engine . duration import baseDurationToTraditionalNumber
from template . helper import compress
#Our Modules
from engine . input_apcmini import apcMiniInput
DEFAULT_FACTOR = 1 #for the GUI.
#Swing Lookup Table for functions and callbacks
_percentToSwing_Table = { }
_swingToPercent_Table = { }
for value in range ( - 100 , 100 + 1 ) :
#Lookup table.
if value == 0 :
result = 0
elif value > 80 : #81% - 100% is 0.33 - 0.5
result = compress ( value , 81 , 100 , 0.33 , 0.5 )
elif value > 30 : #31% - 80% is 0.15 - 0.33
result = compress ( value , 31 , 80 , 0.15 , 0.32 )
else :
result = compress ( value , 0 , 30 , 0.01 , 0.14 )
r = round ( result , 8 )
_percentToSwing_Table [ value ] = r
_swingToPercent_Table [ r ] = value #TODO: this is risky! it only works because we round to digits and percents are integers.
#New callbacks
class ClientCallbacks ( Callbacks ) : #inherits from the templates api callbacks
def __init__ ( self ) :
super ( ) . __init__ ( )
self . timeSignatureChanged = [ ]
self . scoreChanged = [ ]
self . numberOfMeasuresChanged = [ ]
self . trackStructureChanged = [ ]
self . trackMetaDataChanged = [ ]
self . patternChanged = [ ]
self . stepChanged = [ ]
self . removeStep = [ ]
self . exportCacheChanged = [ ]
self . subdivisionsChanged = [ ]
self . quarterNotesPerMinuteChanged = [ ]
self . loopChanged = [ ]
self . loopMeasureFactorChanged = [ ]
self . patternLengthMultiplicatorChanged = [ ]
self . swingChanged = [ ]
self . swingPercentChanged = [ ]
self . currentTrackChanged = [ ]
def _quarterNotesPerMinuteChanged ( self ) :
""" There is one tempo for the entire song in quarter notes per mintue.
score . isTransportMaster to False means we do not create our own changes
and leave everything to the default . Negative values are not possible """
if session . data . tempoMap . isTransportMaster :
export = session . data . tempoMap . getQuarterNotesPerMinute ( )
else :
export = None
for func in self . quarterNotesPerMinuteChanged :
func ( export )
callbacks . _dataChanged ( )
def _setPlaybackTicks ( self ) : #Differs from the template because it has subdivisions and offset
ppqn = cbox . Transport . status ( ) . pos_ppqn * session . data . subdivisions - session . data . cachedOffsetInTicks
status = playbackStatus ( )
for func in self . setPlaybackTicks :
func ( ppqn , status )
def _loopChanged ( self , measurenumber , loopStart , loopEnd ) :
export = measurenumber
for func in self . loopChanged :
func ( export )
def _loopMeasureFactorChanged ( self ) :
""" Very atomic callback. Used only for one value: how many measures are in one loop """
export = session . data . loopMeasureFactor
for func in self . loopMeasureFactorChanged :
func ( export )
def _timeSignatureChanged ( self ) :
nr = session . data . howManyUnits
typ = session . data . whatTypeOfUnit
for func in self . timeSignatureChanged :
func ( nr , typ )
##All patterns and tracks need updates:
for track in session . data . tracks :
self . _patternChanged ( track )
self . _subdivisionsChanged ( ) #update subdivisions. We do not include them in the score or track export on purpose.
callbacks . _dataChanged ( )
def _subdivisionsChanged ( self ) :
""" Subdivisions are tricky, therefore we keep them isolated in their own callback.
You don ' t need to redraw anything if you don ' t want to . One recommendation is to
draw every n step a little more important ( bigger , different color ) .
where n = subdivions
We also place JACK BBT via tempoMap here because we need it every time the time sig
changes ( which calls _subdivisionsChanged ) and if subdivisions change .
"""
typ = baseDurationToTraditionalNumber [ session . data . whatTypeOfUnit ]
nr = session . data . howManyUnits
tradNr = int ( nr / session . data . subdivisions )
#JACK BBT for Timebase Master. No matter if we are master at the moment or not.
if tradNr == nr / session . data . subdivisions :
#Easier to read than the else case. Not possible with 9 Steps per Pattern in Groups of 2 because that is a 4.5/4 timesig.
session . data . tempoMap . setTimeSignature ( tradNr , typ )
else :
#Always possible, compared to first if case.
session . data . tempoMap . setTimeSignature ( nr , typ * session . data . subdivisions )
export = session . data . subdivisions
for func in self . subdivisionsChanged :
func ( export )
callbacks . _dataChanged ( )
def _scoreChanged ( self ) :
""" This includes the time signature as well, but is not send on a timesig change.
A timesig change needs to update all tracks as playback as well as for the GUI
so it is its own callback .
Use this for fast and inexpensive updates like drawing a label or adjusting the
GUI that shows your measure groups ( a label each 8 measures or so ) """
export = session . data . export ( )
for func in self . scoreChanged :
func ( export )
callbacks . _dataChanged ( )
def _exportCacheChanged ( self , track ) :
""" Send the export cache for GUI caching reasons. Don ' t react by redrawing immediately!
This is sent often , redundantly and more than you need .
Example : If you only show one pattern at the same time use this to cache the data
in all hidden patterns and redraw only if you change the active pattern .
You can react to real changes in your active pattern by using patternChanged
and stepChanged . """
export = track . export ( )
for func in self . exportCacheChanged :
func ( export )
def _patternChanged ( self , track ) :
""" each track has only one pattern. We can identify the pattern by track and vice versa.
Don ' t use this to react to clicks on the pattern editor. Use stepChanged instead and
keep book of your incremental updates .
This is used for the whole pattern : timesig changes , invert , clean etc .
"""
export = track . export ( )
self . _exportCacheChanged ( track )
for func in self . patternChanged :
func ( export )
callbacks . _dataChanged ( )
def _stepChanged ( self , track , stepDict ) :
""" A simple GUI will most like not listen to that callback since they
already changed the step on their side . Only useful for parallel
views .
We do not export anything but just sent back the change we received
as dict message . """
self . _exportCacheChanged ( track )
for func in self . stepChanged :
func ( stepDict )
callbacks . _dataChanged ( )
def _removeStep ( self , track , index , pitch , factor ) :
""" Opposite of _stepChanged """
self . _exportCacheChanged ( track )
stepDict = { " index " : index ,
" factor " : factor ,
" pitch " : pitch ,
" velocity " : 0 , #it is off.
}
for func in self . removeStep :
func ( stepDict )
callbacks . _dataChanged ( )
def _trackStructureChanged ( self , track ) :
""" update one track structure. Does not export cbox.
Also includes transposition """
export = track . export ( )
for func in self . trackStructureChanged :
func ( export )
callbacks . _dataChanged ( )
def _trackMetaDataChanged ( self , track ) :
""" a low cost function that should not trigger anything costly to redraw
but some text and simple widgets . """
export = track . export ( )
for func in self . trackMetaDataChanged :
func ( export )
callbacks . _dataChanged ( )
def _numberOfMeasuresChanged ( self ) :
""" The whole song got longer or shorter.
Number of measures is the same for all tracks . """
export = session . data . export ( )
for func in self . numberOfMeasuresChanged :
func ( export )
callbacks . _dataChanged ( )
def _patternLengthMultiplicatorChanged ( self , track ) :
export = track . export ( )
for func in self . patternLengthMultiplicatorChanged :
func ( export )
self . _patternChanged ( track ) #includes dataChanged
self . _subdivisionsChanged ( )
callbacks . _dataChanged ( )
def _swingChanged ( self ) :
""" Global for the whole song """
export = session . data . swing
for func in self . swingChanged :
func ( export )
for func in self . swingPercentChanged :
func ( _swingToPercent_Table [ export ] )
callbacks . _dataChanged ( )
def _currentTrackChanged ( self , track ) :
""" The engine has no concept of a current track. This is a callback to sync different
GUIs and midi controllers that use the system of a current track .
Therefore this callback will never get called on engine changes .
We send this once after load ( always track 0 ) and then non - engine is responsible for calling
the api function api . changeCurrentTrack ( trackId )
"""
export = track . export ( )
for func in self . currentTrackChanged :
func ( export )
#no data changed.
#Inject our derived Callbacks into the parent module
template . engine . api . callbacks = ClientCallbacks ( )
from template . engine . api import callbacks
_templateStartEngine = startEngine
def updatePlayback ( ) :
#TODO: use template.sequencer.py internal updates instead
cbox . Document . get_song ( ) . update_playback ( )
def startEngine ( nsmClient , additionalData : dict = { } ) :
_templateStartEngine ( nsmClient , additionalData ) #loads save files or creates empty structure.
session . inLoopMode = None # remember if we are in loop mode or not. Positive value is None or a tuple with start and end
#Activate apc mini controller
apcMiniInput . start ( )
apcMiniInput . setMidiInputActive ( True ) #MidiInput is just the general "activate processing"
#Send the current track, to at least tell apcMini where we are.
changeCurrentTrack ( id ( session . data . tracks [ 0 ] ) ) # See callback docstring. This is purely for GUI and midi-controller convenience. No engine data is touched.
#Send initial Callbacks to create the first GUI state.
#The order of initial callbacks must not change to avoid GUI problems.
#For example it is important that the tracks get created first and only then the number of measures
logger . info ( " Sending initial callbacks to GUI and APCmini " )
callbacks . _numberOfTracksChanged ( )
callbacks . _timeSignatureChanged ( )
callbacks . _numberOfMeasuresChanged ( )
callbacks . _subdivisionsChanged ( )
callbacks . _quarterNotesPerMinuteChanged ( )
callbacks . _loopMeasureFactorChanged ( )
callbacks . _swingChanged ( )
for track in session . data . tracks :
callbacks . _trackMetaDataChanged ( track ) #for colors, scale and simpleNoteNames
callbacks . _patternLengthMultiplicatorChanged ( track ) #for colors, scale and simpleNoteNames
session . data . buildAllTracks ( buildSongDuration = True ) #will set to max track length, we always have a song duration.
updatePlayback ( )
logger . info ( " Patroneo api startEngine complete " )
def _loopOff ( ) :
session . data . buildSongDuration ( ) #no parameter removes the loop
updatePlayback ( )
session . inLoopMode = None
callbacks . _loopChanged ( None , None , None )
def _loopNow ( ) :
now = cbox . Transport . status ( ) . pos_ppqn
_setLoop ( now )
def _setLoop ( loopMeasureAroundPpqn : int ) :
""" This function is used with context.
The loopFactor , how many measures are looped , is saved value """
if loopMeasureAroundPpqn < 0 :
_loopOff ( )
return
#loopMeasureAroundPpqn = max(0, loopMeasureAroundPpqn + session.data.cachedOffsetInTicks)
loopMeasureAroundPpqn = loopMeasureAroundPpqn - session . data . cachedOffsetInTicks
loopStart , loopEnd = session . data . buildSongDuration ( loopMeasureAroundPpqn ) #includes global tick offset
session . data . _lastLoopStart = loopStart
updatePlayback ( )
session . inLoopMode = ( loopStart , loopEnd )
assert loopStart < = loopMeasureAroundPpqn + session . data . cachedOffsetInTicks < loopEnd , ( loopStart , loopMeasureAroundPpqn , loopEnd )
if not playbackStatus ( ) :
cbox . Transport . play ( )
oneMeasureInTicks = ( session . data . howManyUnits * session . data . whatTypeOfUnit ) / session . data . subdivisions
measurenumber , rest = divmod ( loopStart - session . data . cachedOffsetInTicks , oneMeasureInTicks ) #We substract the offset from the measure number because for a GUI it is still the visible measure number
callbacks . _loopChanged ( int ( measurenumber ) , loopStart , loopEnd )
def setLoopMeasureFactor ( newValue : int ) :
""" How many measures are looped at once. """
if newValue < 1 :
newValue = 1
session . data . loopMeasureFactor = newValue
callbacks . _loopMeasureFactorChanged ( )
if session . inLoopMode :
_setLoop ( session . data . _lastLoopStart )
def toggleLoop ( ) :
""" Plays the current measure as loop.
Current measure is where the playback cursor is
session . inLoopMode is a tuple ( start , end )
"""
if session . inLoopMode :
_loopOff ( )
else :
_loopNow ( )
def rewind ( ) :
""" template.toStart, but removes our loop """
_loopOff ( )
toStart ( )
def seek ( value ) :
""" override template one, which does not have a loop """
if value < 0 :
value = 0
if session . inLoopMode and not session . inLoopMode [ 0 ] < = value < session . inLoopMode [ 1 ] : #if you seek outside the loop the loop will be destroyed.
toggleLoop ( )
value = max ( 0 , value + session . data . cachedOffsetInTicks )
cbox . Transport . seek_ppqn ( value )
def seekMeasureLeft ( ) :
""" This skips one base measure, not the multiplicator one """
now = cbox . Transport . status ( ) . pos_ppqn
oneMeasureInTicks = ( session . data . howManyUnits * session . data . whatTypeOfUnit ) / session . data . subdivisions
seek ( now - oneMeasureInTicks )
def seekMeasureRight ( ) :
""" This skips one base measure, not the multiplicator one """
now = cbox . Transport . status ( ) . pos_ppqn
oneMeasureInTicks = ( session . data . howManyUnits * session . data . whatTypeOfUnit ) / session . data . subdivisions
seek ( now + oneMeasureInTicks )
def getGlobalOffset ( ) :
""" Return the current offsets in full measures + free tick value 3rd: Cached abolute tick value
gets updated everytime the time signature changes or setGlobalOffset is called """
return session . data . globalOffsetMeasures , session . data . globalOffsetTicks , session . data . cachedOffsetInTicks
def setGlobalOffset ( fullMeasures , absoluteTicks ) :
session . history . register ( lambda f = session . data . globalOffsetMeasures , t = session . data . globalOffsetTicks : setGlobalOffset ( f , t ) , descriptionString = " Global Rhythm Offset " )
session . data . globalOffsetMeasures = fullMeasures
session . data . globalOffsetTicks = absoluteTicks
session . data . buildAllTracks ( ) #includes refreshing the tick offset cache
updatePlayback ( )
##Score
def set_quarterNotesPerMinute ( value ) :
""" Transport Master is set implicitly. If value == None Patroneo will switch into
JackTransport Slave mode """
if session . data . tempoMap . isTransportMaster :
oldValue = session . data . tempoMap . getQuarterNotesPerMinute ( )
else :
oldValue = None
if oldValue == value : return # no change
session . history . register ( lambda v = oldValue : set_quarterNotesPerMinute ( v ) , descriptionString = " Tempo " )
if value is None :
session . data . tempoMap . isTransportMaster = False #triggers rebuild
elif value == " on " :
assert not session . data . tempoMap . isTransportMaster
#keep old bpm value. 120 bpm is default.
session . data . tempoMap . isTransportMaster = True #triggers rebuild
else :
assert value > 0
session . data . tempoMap . setQuarterNotesPerMinute ( value )
session . data . tempoMap . isTransportMaster = True #triggers rebuild
#Does not need track rebuilding
updatePlayback ( )
callbacks . _quarterNotesPerMinuteChanged ( )
def set_whatTypeOfUnit ( ticks ) :
""" Denominator of Time Signature = Each Group produces a Quarter """
if session . data . whatTypeOfUnit == ticks : return #no change
session . history . register ( lambda v = session . data . whatTypeOfUnit : set_whatTypeOfUnit ( v ) , descriptionString = " Group Duration " )
session . data . whatTypeOfUnit = ticks
session . data . buildAllTracks ( )
if session . inLoopMode :
_loopNow ( )
updatePlayback ( )
callbacks . _timeSignatureChanged ( )
def set_howManyUnits ( value ) :
""" Numerator of Time Signature = Steps per Pattern """
if session . data . howManyUnits == value : return #no change
session . history . register ( lambda v = session . data . howManyUnits : set_howManyUnits ( v ) , descriptionString = " Steps per Pattern " )
session . data . howManyUnits = value
session . data . buildAllTracks ( )
if session . inLoopMode :
_loopNow ( )
updatePlayback ( )
callbacks . _timeSignatureChanged ( )
def set_subdivisions ( value ) :
""" In groups of 1, 2, 4 """
if session . data . subdivisions == value : return #no change
session . history . register ( lambda v = session . data . subdivisions : set_subdivisions ( v ) , descriptionString = " Group Size " )
session . data . subdivisions = value
session . data . buildAllTracks ( )
if session . inLoopMode :
_loopNow ( )
updatePlayback ( )
callbacks . _subdivisionsChanged ( )
def convert_subdivisions ( value , errorHandling ) :
""" " errorHandling can be fail, delete or merge """
oldValue = session . data . subdivisions
result = session . data . convertSubdivisions ( value , errorHandling )
if result : #bool for success
session . history . register ( lambda v = oldValue : convert_subdivisions ( v , " delete " ) , descriptionString = " Convert Grouping " ) #the error handling = delete should not matter at all. We are always in a position where this is possible because we just converted to the current state from a valid one.
session . data . buildAllTracks ( )
updatePlayback ( )
callbacks . _timeSignatureChanged ( ) #includes pattern changed for all tracks and subdivisions
#for tr in session.data.tracks:
# callbacks._patternChanged(tr)
else :
callbacks . _subdivisionsChanged ( ) #to reset the GUI value back to the working one.
if session . inLoopMode :
_loopNow ( )
return result
def set_swing ( value : float ) :
""" A swing that feels natural is not linear. This function sets the absolute value
between - 0.5 and 0.5 but you most likely want to use setSwingPercent which has a non - linear
mapping """
if value < - 0.5 or value > 0.5 :
logger . warning ( f " Swing can only be between -0.5 and 0.5, not { value } " )
return
session . history . register ( lambda v = session . data . swing : set_swing ( v ) , descriptionString = " Swing " )
session . data . swing = value
session . data . buildAllTracks ( )
updatePlayback ( )
callbacks . _swingChanged ( )
def setSwingPercent ( value : int ) :
""" Give value between -100 and 100. 0 is " off " and default.
It will be converted to a number between - 0.5 and 0.5 behind the scenes . This is the value
that gets saved .
Our function will use a lookup - table to convert percentage in a musical way .
That said , the GUI and the apcMini only go from 0 to 100 , the negative range is ignored .
The first 80 % will be used for normal musical values . The other 20 for more extreme sounds .
"""
if value < - 100 or value > 100 :
logger . warning ( f " Swing in percent can only be between -100 and +100, not { value } " )
return
set_swing ( _percentToSwing_Table [ value ] ) #handles undo and callbacks
def set_numberOfMeasures ( value ) :
if session . data . numberOfMeasures == value :
return
session . history . register ( lambda v = session . data . numberOfMeasures : set_numberOfMeasures ( v ) , descriptionString = " Measures per Track " )
session . data . numberOfMeasures = value
session . data . buildSongDuration ( )
updatePlayback ( )
callbacks . _numberOfMeasuresChanged ( )
callbacks . _scoreChanged ( ) #redundant but cheap and convenient
def set_measuresPerGroup ( value ) :
if session . data . measuresPerGroup == value :
return
session . history . register ( lambda v = session . data . measuresPerGroup : set_measuresPerGroup ( v ) , descriptionString = " Measures per Group " )
session . data . measuresPerGroup = value
#No playback change
callbacks . _scoreChanged ( )
def changeTrackName ( trackId , name ) :
""" The template gurantees a unique, sanitized name across tracks and groups """
track = session . data . trackById ( trackId )
if not track : return
if not name . lower ( ) in ( gr . lower ( ) for gr in getGroups ( ) ) :
session . history . register ( lambda trId = trackId , v = track . sequencerInterface . name : changeTrackName ( trId , v ) , descriptionString = " Track Name " )
track . sequencerInterface . name = name #sanitizes on its own. Checks for duplicate tracks but not groups
callbacks . _trackMetaDataChanged ( track )
def changeTrackColor ( trackId , colorInHex ) :
""" Expects " #rrggbb """
track = session . data . trackById ( trackId )
if not track : return
assert len ( colorInHex ) == 7 , colorInHex
session . history . register ( lambda trId = trackId , v = track . color : changeTrackColor ( trId , v ) , descriptionString = " Track Color " )
track . color = colorInHex
callbacks . _trackMetaDataChanged ( track )
def changeTrackMidiChannel ( trackId , newChannel : int ) :
""" newChannel is 1-16, we convert here to internal format 0-15.
Callbacks export data sends 1 - 16 again """
if newChannel < 1 or newChannel > 16 :
logger . warning ( f " Midi Channel must be between 1-16 for this function, was: { newChannel } . Doing nothing. " )
return
track = session . data . trackById ( trackId )
if not track : return
session . history . register ( lambda trId = trackId , v = track . midiChannel : changeTrackMidiChannel ( trId , v ) , descriptionString = " Track Midi Channel " )
track . midiChannel = newChannel - 1
track . pattern . buildExportCache ( )
track . buildTrack ( )
updatePlayback ( )
callbacks . _trackMetaDataChanged ( track )
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
def changeTrackStepDelayWrapAround ( trackId , newState : bool ) :
track = session . data . trackById ( trackId )
if not track : return
session . history . register ( lambda trId = trackId , v = track . stepDelayWrapAround : changeTrackStepDelayWrapAround ( trId , v ) , descriptionString = " Track Step Delay Wrap-Around " )
track . stepDelayWrapAround = newState
track . pattern . buildExportCache ( )
track . buildTrack ( )
updatePlayback ( )
callbacks . _trackMetaDataChanged ( track )
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
def changeTrackRepeatDiminishedPatternInItself ( trackId , newState : bool ) :
track = session . data . trackById ( trackId )
if not track : return
session . history . register ( lambda trId = trackId , v = track . repeatDiminishedPatternInItself : changeTrackRepeatDiminishedPatternInItself ( trId , v ) , descriptionString = " Track Repeat Diminished Pattern in itself " )
track . repeatDiminishedPatternInItself = newState
track . pattern . buildExportCache ( )
track . buildTrack ( )
updatePlayback ( )
callbacks . _trackMetaDataChanged ( track )
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
def changeCurrentTrack ( trackId ) :
""" This is for communication between the GUI and APCmini controller. The engine has no concept
of a current track . """
track = session . data . trackById ( trackId )
assert track , trackId
callbacks . _currentTrackChanged ( track )
def currentTrackBy ( currentTrackId , value : int ) :
""" Convenience for the apcMiniController or a GUI that wants shortcuts.
Only use + 1 and - 1 for value for stepping .
We do NOT test for other values if the overshoot the session . data . tracks index ! !
We need to know what the current track is because the engine doesn ' t know it.
Ignores invisible tracks , aka tracks in a GUI - folded group .
"""
assert value in ( - 1 , 1 ) , value
currentTrack = session . data . trackById ( currentTrackId )
assert currentTrack , currentTrackId
onlyVisibleTracks = [ track for track in session . data . tracks if track . visible ]
if not onlyVisibleTracks : return ; #all tracks are hidden
if not currentTrack in onlyVisibleTracks :
changeCurrentTrack ( id ( onlyVisibleTracks [ 0 ] ) )
return #an impossible situation.
currentIndex = onlyVisibleTracks . index ( currentTrack )
if value == - 1 and currentIndex == 0 : return #already first track
elif value == 1 and len ( onlyVisibleTracks ) == currentIndex + 1 : return #already last track
newCurrentTrack = onlyVisibleTracks [ currentIndex + value ]
changeCurrentTrack ( id ( newCurrentTrack ) )
def addTrack ( scale = None ) :
if scale :
assert type ( scale ) == tuple
track = session . data . addTrack ( scale = scale )
assert track
trackId = id ( track )
session . history . register ( lambda trId = trackId : deleteTrack ( trId ) , descriptionString = " Add Track " )
session . data . sortTracks ( ) #in place sorting for groups
callbacks . _numberOfTracksChanged ( )
return trackId
def createSiblingTrack ( trackId ) : #aka clone track
""" Create a new track with scale, color and jack midi out the same as the given track.
The jack midi out will be independent after creation , but connected to the same instrument
( if any ) """
track = session . data . trackById ( trackId )
if not track : return
assert type ( track . pattern . scale ) == tuple
newTrack = session . data . addTrack ( name = track . sequencerInterface . name , scale = track . pattern . scale , color = track . color , simpleNoteNames = track . pattern . simpleNoteNames ) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2"
newTrack . pattern . averageVelocity = track . pattern . averageVelocity
newTrack . patternLengthMultiplicator = track . patternLengthMultiplicator
newTrack . midiChannel = track . midiChannel
if track . group :
session . data . setGroup ( newTrack , track . group ) #includes session.data.buildAllTracks()
else :
jackConnections = cbox . JackIO . get_connected_ports ( track . sequencerInterface . cboxPortName ( ) )
for port in jackConnections :
cbox . JackIO . port_connect ( newTrack . sequencerInterface . cboxPortName ( ) , port )
#Move new track to neighbour the old one.
oldIndex = session . data . tracks . index ( track )
newIndex = session . data . tracks . index ( newTrack )
newTrackAgain = session . data . tracks . pop ( newIndex )
assert newTrackAgain is newTrack
session . data . tracks . insert ( oldIndex + 1 , newTrackAgain )
session . history . register ( lambda trId = id ( newTrackAgain ) : deleteTrack ( trId ) , descriptionString = " Clone Track " )
session . data . sortTracks ( ) #in place sorting for groups
callbacks . _numberOfTracksChanged ( )
return newTrack . export ( )
def _reinsertDeletedTrack ( track , trackIndex ) :
""" For undo """
track . sequencerInterface . recreateThroughUndo ( )
session . data . tracks . insert ( trackIndex , track )
session . history . register ( lambda trId = id ( track ) : deleteTrack ( trId ) , descriptionString = " Add deleted Track again " )
updatePlayback ( )
callbacks . _numberOfTracksChanged ( )
def deleteTrack ( trackId ) :
""" indirectly calls session.data.buildAllTracks() through group change.
This is wasteful , but it acceptable . We let the code stay simple in exchange for redundant
re - building of all tracks .
"""
track = session . data . trackById ( trackId )
if not track : return
oldIndex = session . data . tracks . index ( track )
with session . history . sequence ( " Delete Track " ) :
setTrackGroup ( trackId , " " ) #has it's own undo
deletedTrack = session . data . deleteTrack ( track )
if not session . data . tracks : #always keep at least one track
addTrack ( ) #has it's own undo
session . history . register ( lambda tr = deletedTrack , pos = oldIndex : _reinsertDeletedTrack ( tr , pos ) , descriptionString = " Delete Track " )
else :
session . history . register ( lambda tr = deletedTrack , pos = oldIndex : _reinsertDeletedTrack ( tr , pos ) , descriptionString = " Delete Track " )
updatePlayback ( )
callbacks . _numberOfTracksChanged ( ) #TODO: throws a console error "port not found". but that is not critical.
def moveTrack ( trackId , newIndex ) :
""" index is 0 based.
With groups involved free movevement is not allowed anymore .
All tracks of a group have to be next to each other and have to be in that exact place
in session . data . tracks , so that jack metadata port order works , groups or not .
"""
track = session . data . trackById ( trackId )
if not track : return