#! /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 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
from typing import List , Dict , Tuple , Iterable , Union
#Third Party Modules
from template . calfbox import cbox
#Template Modules
from . data import Data
from . metronome import Metronome
from . duration import traditionalNumberToBaseDuration , MAXIMUM_TICK_DURATION
#Client Modules
from engine . config import * #includes METADATA only. No other environmental setup is executed.
class Score ( Data ) :
""" Manages and holds tracks
Has a mutable list of Track instances . This is the official order . Rearranging happens here .
Order ist reflected in JACK through metadata . UIs should adopt it as well .
Score . TrackClass needs to be injected with your Track class .
Score . TrackClass needs to have a SequencerInterface of type SequencerInterface
self . tracks holds only active tracks . Which means tracks that produce sound
That does not mean that they are visible or editable for the user .
Does NOT hold deleted tracks in the undo storage . You need to hold these tracks in memory
yourself before calling score . delete . For example Laborejo registers the Track instance
in our history module which keeps the instance alive .
Special Tracks do not need to be created here . E . g . a metronome can be just a track .
"""
TrackClass = None
def __init__ ( self , parentSession ) :
assert Score . TrackClass
super ( ) . __init__ ( parentSession )
self . tracks = [ ] #see docstring
self . tempoMap = TempoMap ( parentData = self )
self . _template_processAfterInit ( )
self . _tracksFailedLookup = [ ]
def _template_processAfterInit ( self ) : #needs a different name because there is an inherited class with the same method.
""" Call this after either init or instanceFromSerializedData """
if METADATA [ " metronome " ] :
self . metronome = Metronome ( parentData = self ) #Purely dynamic structure. No save/load. No undo/redo
#Whole Score / Song
def buildSongDuration ( self , startEndTuple = None ) :
""" Set playback length for the entire score or a loop.
Why is start the end - tick of the song ?
Starting from 0 would create an actual loop from the start to end .
We want the song to play only once .
The cbox way of doing that is to set the loop range to zero at the end of the track .
Zero length is stop .
"""
if startEndTuple is None :
longestTrackDuration = max ( track . sequencerInterface . cachedDuration for track in self . tracks )
start = longestTrackDuration
end = longestTrackDuration
else :
start , end = startEndTuple
cbox . Document . get_song ( ) . set_loop ( start , end )
#Tracks
def addTrack ( self , name : str = " " ) :
""" Create and add a new track. Not an existing one """
track = Score . TrackClass ( parentData = self , name = name ) # type: ignore # mypy doesn't know that this is an injected class variable
assert track . sequencerInterface
self . tracks . append ( track )
return track
def deleteTrack ( self , track ) :
track . sequencerInterface . prepareForDeletion ( )
self . tracks . remove ( track )
return track #for undo
def updateJackMetadataSorting ( self ) :
""" Add this to you " tracksChanged " or " numberOfTracksChanged " callback.
Tell cbox to reorder the tracks by metadata . Deleted ports are automatically removed by JACK .
It is advised to use this in a controlled manner . There is no Score - internal check if
self . tracks changed and subsequent sorting . Multiple track changes in a row are common ,
therefore the place to update jack order is in the API , where the new track order is also
sent to the UI .
We also check if the track is ' deactivated ' by probing track . cboxMidiOutUuid .
Patroneo uses prepareForDeletion to deactive the tracks standalone track but keeps the
interface around for later use .
"""
order = { portName : index for index , portName in enumerate ( track . sequencerInterface . cboxPortName ( ) for track in self . tracks if track . sequencerInterface . cboxMidiOutUuid ) }
try :
cbox . JackIO . Metadata . set_all_port_order ( order )
except Exception as e : #No Jack Meta Data or Error with ports.
logger . error ( e )
def trackById ( self , trackId : int ) :
""" Returns a track or None, if not found """
for track in self . tracks :
if trackId == id ( track ) :
return track
else :
#Previously this crashed with a ValueError. However, after a rare bug that a gui widget focussed out because a track was deleted and then tried to send its value to the engine we realize that this lookup can gracefully return None.
#Nothing will break: Functions that are not aware yet, that None is an option will crash when they try to access None as a track object. For this case we present the following logger error:
if not trackId in self . _tracksFailedLookup :
logger . error ( f " Track { trackId } not found. Current Tracks: { [ id ( tr ) for tr in self . tracks ] } " )
self . _tracksFailedLookup . append ( trackId ) #prevent multiple error messages for the same track in a row.
return None
#raise ValueError(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}")
#Save / Load / Export
def serialize ( self ) - > dict :
return {
" tracks " : [ track . serialize ( ) for track in self . tracks ] ,
" tempoMap " : self . tempoMap . serialize ( ) ,
}
@classmethod
def instanceFromSerializedData ( cls , parentSession , serializedData ) :
""" The entry function to create a score from saved data. It is called by the session.
This functions triggers a tree of other createInstanceFromSerializedData which finally
return the score , which gets saved in the session .
The serializedData is already converted to primitive python types from json ,
but nothing more . Here we create the actual objects . """
self = cls . __new__ ( cls )
Score . copyFromSerializedData ( parentSession , serializedData , self )
return self
@staticmethod
def copyFromSerializedData ( parentSession , serializedData , childObject ) :
"""
childObject is a Score or similar .
Because this is an actual parent class we can ' t use instanceFromSerializedData in a child
without actually creating an object . Long story short , use this to generate the data and
use it in your child class . If the Data class is used standalone it still can be used . """
childObject . parentSession = parentSession
loadedTracks = [ ]
for trackSrzData in serializedData [ " tracks " ] :
track = Score . TrackClass . instanceFromSerializedData ( parentData = childObject , serializedData = trackSrzData )
loadedTracks . append ( track )
childObject . tracks = loadedTracks
childObject . tempoMap = TempoMap . instanceFromSerializedData ( parentData = childObject , serializedData = serializedData [ " tempoMap " ] )
childObject . _template_processAfterInit ( )
def export ( self ) - > dict :
return {
" numberOfTracks " : len ( self . tracks ) ,
#"duration" : self.
}
class _Interface ( object ) :
#no load or save. Do that in the child classes.
def __init__ ( self , parentTrack , name = None ) :
self . parentTrack = parentTrack
self . parentData = parentTrack . parentData
self . _name = self . _isNameAvailable ( name ) if name else str ( id ( self ) )
self . _enabled = True
self . _processAfterInit ( )
def _processAfterInit ( self ) :
self . _cachedPatterns = [ ] #makes undo after delete possible
self . calfboxTrack = cbox . Document . get_song ( ) . add_track ( )
self . calfboxTrack . set_name ( self . name ) #only cosmetic and cbox internal. Useful for debugging, not used in jack.
#Caches and other Non-Saved attributes
self . cachedDuration = 0 #used by parentData.buildSongDuration to calculate the overall length of the song by checking all tracks.
@property
def name ( self ) :
return self . _name
@property
def enabled ( self ) - > bool :
return self . _enabled
def _isNameAvailable ( self , name : str ) :
""" Check if the name is free. If not increment """
name = ' ' . join ( ch for ch in name if ch . isalnum ( ) or ch in ( " " , " _ " , " - " ) ) #sanitize
name = " " . join ( name . split ( ) ) #remove double spaces
while name in [ tr . sequencerInterface . name for tr in self . parentData . tracks ] :
beforeLastChar = name [ - 2 ]
lastChar = name [ - 1 ]
if beforeLastChar == " " and lastChar . isalnum ( ) and lastChar not in ( " 9 " , " z " , " Z " ) :
#Pattern is "Trackname A" or "Trackname 1" which can be incremented.
name = name [ : - 1 ] + chr ( ord ( name [ - 1 ] ) + 1 )
else :
name = name + " A "
return name
def _updatePlayback ( self ) :
self . parentData . buildSongDuration ( )
cbox . Document . get_song ( ) . update_playback ( )
def setTrack ( self , blobs : Iterable ) : #(bytes-blob, position, length)
""" Converts an Iterable of (bytes-blob, position, length) to cbox patterns, clips and adds
them to an empty track , which replaces the current one .
Simplest version is to send one blob at position 0 with its length . """
#self.calfboxTrack.delete() #cbox clear data, not python structure
#self.calfboxTrack = cbox.Document.get_song().add_track()
#self.calfboxTrack.set_external_output(self.cboxMidiOutUuid)
self . calfboxTrack . clear_clips ( )
self . _cachedPatterns = [ ] #makes undo after delete possible
pos = 0
for blob , pos , leng in blobs :
if leng > 0 :
pat = cbox . Document . get_song ( ) . pattern_from_blob ( blob , leng )
t = ( pat , pos , leng )
self . _cachedPatterns . append ( t )
length = 0
for pattern , position , length in self . _cachedPatterns :
if length > 0 :
self . calfboxTrack . add_clip ( position , 0 , length , pattern ) #pos, offset, length, pattern.
self . cachedDuration = pos + length #use the last set values
self . _updatePlayback ( )
def insertEmptyClip ( self ) :
""" Convenience function to make recording into an empty song possible.
Will be removed by self . setTrack . """
blob = bytes ( )
#We do not need any content #blob += cbox.Pattern.serialize_event(0, 0x80, 60, 64) # note off
pattern = cbox . Document . get_song ( ) . pattern_from_blob ( blob , MAXIMUM_TICK_DURATION ) #blog, length
self . calfboxTrack . add_clip ( 0 , 0 , MAXIMUM_TICK_DURATION , pattern ) #pos, offset, length, pattern.
self . cachedDuration = MAXIMUM_TICK_DURATION
self . _updatePlayback ( )
class _Subtrack ( object ) :
""" Generates its own midi data and caches the resulting track but does not have a name
nor its own jack midi port . Instead it is attached to an SequencerInterface .
It is SequencerInterface because that has a jack midi port , and not Interface itself .
Only used by SequencerInterface internally . Creation is done by its methods .
This is not a child class because a top level class ' code is easier to read.
Intended usecase is to add CC messages as one subtrack per CC number ( e . g . CC7 = Volume ) .
Of course you could just put CCs together with notes in the main Interface
and not use SubTracks . But where is the fun in that ? """
def __init__ ( self , parentSequencerInterface ) :
self . _cachedPatterns = [ ] #makes undo after delete possible
self . parentSequencerInterface = parentSequencerInterface
self . calfboxSubTrack = cbox . Document . get_song ( ) . add_track ( )
self . calfboxSubTrack . set_external_output ( parentSequencerInterface . cboxMidiOutUuid )
def prepareForDeletion ( self ) :
self . calfboxSubTrack . delete ( ) #in place self deletion.
self . calfboxSubTrack = None
cbox . Document . get_song ( ) . update_playback ( )
def recreateThroughUndo ( self ) :
assert self . calfboxSubTrack is None , self . calfboxSubTrack
self . calfboxSubTrack = cbox . Document . get_song ( ) . add_track ( )
for pattern , position , length in self . _cachedPatterns :
self . calfboxSubTrack . add_clip ( position , 0 , length , pattern ) #pos, offset, length, pattern.
cbox . Document . get_song ( ) . update_playback ( )
def setSubtrack ( self , blobs : Iterable ) : #(bytes-blob, position, length)
""" Does not add to the parents cached duration. Therefore it will not send data beyond
its parent track length , except if another track pushes the overall duration beyond . """
self . calfboxSubTrack . clear_clips ( )
self . _cachedPatterns = [ ] #makes undo after delete possible
pos = 0
for blob , pos , leng in blobs :
if leng > 0 :
pat = cbox . Document . get_song ( ) . pattern_from_blob ( blob , leng )
t = ( pat , pos , leng )
self . _cachedPatterns . append ( t )
length = 0
for pattern , position , length in self . _cachedPatterns :
if length > 0 :
self . calfboxSubTrack . add_clip ( position , 0 , length , pattern ) #pos, offset, length, pattern.
cbox . Document . get_song ( ) . update_playback ( )
class SequencerInterface ( _Interface ) : #Basically the midi part of a track.
""" A tracks name is the same as the jack midi-out ports name.
The main purpose of the child class is to manage its musical data and regulary
fill self . calfboxTrack with musical data :
Create one ore more patterns , distribute them into clips , add clips to the cboxtrack .
buffer = bytes ( )
buffer + = cbox . Pattern . serialize_event ( startTick , 0x90 , pitch , velocity ) # note on
buffer + = cbox . Pattern . serialize_event ( endTick - 1 , 0x80 , pitch , velocity ) # note off #-1 ticks to create a small logical gap. Does not affect next note on.
pattern = cbox . Document . get_song ( ) . pattern_from_blob ( buffer , oneMeasureInTicks )
self . calfboxTrack . add_clip ( index * oneMeasureInTicks , 0 , oneMeasureInTicks , pattern ) #pos, pattern-internal offset, length, pattern.
Use caches to optimize performance . This is mandatory !
self . cachedDuration = the maximum track length . Used to determine the song playback duration .
"""
def _processAfterInit ( self ) :
#Create midi out and cbox track
logger . info ( f " Creating empty SequencerInterface instance for { self . _name } " )
super ( ) . _processAfterInit ( )
self . cboxMidiOutUuid = cbox . JackIO . create_midi_output ( self . _name )
self . calfboxTrack . set_external_output ( self . cboxMidiOutUuid )
cbox . JackIO . rename_midi_output ( self . cboxMidiOutUuid , self . _name )
self . _subtracks = { } #arbitrary key: _Subtrack(). This is not in Interface itself because Subtracks assume a jack midi out.
self . enable ( self . _enabled )
def enable ( self , enabled ) :
""" This is " mute " , more or less. It only disables the note parts, not CCs or other subtracks.
This means if you switch this on again during playback you will have the correct context . """
if enabled :
#self.calfboxTrack.set_external_output(self.cboxMidiOutUuid) #Old version. Does not prevent hanging notes.
self . calfboxTrack . set_mute ( 0 )
else :
#self.calfboxTrack.set_external_output("") #Old version. Does not prevent hanging notes.
self . calfboxTrack . set_mute ( 1 )
self . _enabled = bool ( enabled )
cbox . Document . get_song ( ) . update_playback ( )
@_Interface . name . setter # type: ignore
def name ( self , value ) :
if not value in ( track . sequencerInterface . name for track in self . parentData . tracks ) :
self . _name = self . _isNameAvailable ( value )
if self . cboxMidiOutUuid : #we could be deactivated
cbox . JackIO . rename_midi_output ( self . cboxMidiOutUuid , self . _name )
def cboxPortName ( self ) - > str :
""" Return the complete jack portname: OurName:PortName """
portname = cbox . JackIO . status ( ) . client_name + " : " + self . name
return portname
def prepareForDeletion ( self ) :
""" Called by score right before this track gets deleted.
This does not mean the track is gone . It can be recovered by
undo . That is why we bother setting calfboxTrack to None
again .
"""
if not self . calfboxTrack : #maybe non-template part deactivated it, like Patroneo groups
return
try :
portlist = cbox . JackIO . get_connected_ports ( self . cboxPortName ( ) )
except : #port not found.
portlist = [ ]
self . _beforeDeleteThisJackMidiWasConnectedTo = portlist
self . calfboxTrack . set_external_output ( " " )
cbox . JackIO . delete_midi_output ( self . cboxMidiOutUuid )
self . calfboxTrack . delete ( ) #in place self deletion.
self . calfboxTrack = None
self . cboxMidiOutUuid = None
#we leave cachedDuration untouched
self . _updatePlayback ( )
def recreateThroughUndo ( self ) :
""" Brings this track back from the dead, in-place.
Assumes this track instance was not in the score but
somewhere in memory . self . prepareForDeletion ( ) was called
in the past which deleted the midi output but not the cbox - midi
data it generated and held """
#Recreate Calfbox Midi Data
assert self . calfboxTrack is None , self . calfboxTrack
self . calfboxTrack = cbox . Document . get_song ( ) . add_track ( )
self . calfboxTrack . set_name ( self . name ) #only cosmetic and cbox internal. Useful for debugging, not used in jack.
for pattern , position , length in self . _cachedPatterns :
self . calfboxTrack . add_clip ( position , 0 , length , pattern ) #pos, offset, length, pattern.
#self.cachedDuration is still valid
#Create MIDI and reconnect Jack
self . cboxMidiOutUuid = cbox . JackIO . create_midi_output ( self . name )
cbox . JackIO . rename_midi_output ( self . cboxMidiOutUuid , self . _name )
self . calfboxTrack . set_external_output ( self . cboxMidiOutUuid )
for port in self . _beforeDeleteThisJackMidiWasConnectedTo :
try :
cbox . JackIO . port_connect ( self . cboxPortName ( ) , port )
except : #external connected synth is maybe gone. Prevent crash.
logger . warning ( f " Previously external connection { port } is gone. Can ' t connect anymore. " )
#Make it official
self . _updatePlayback ( )
def setSubtrack ( self , key , blobs : Iterable ) : #(bytes-blob, position, length)
""" Creates a new subtrack if key is unknown
Forward data to the real function
Simplest version is to send one blob at position 0 with its length """
if not key in self . _subtracks :
self . _subtracks [ key ] = _Subtrack ( parentSequencerInterface = self )
assert self . _subtracks [ key ] , key
assert isinstance ( self . _subtracks [ key ] , _Subtrack ) , type ( self . _subtracks [ key ] )
self . _subtracks [ key ] . setSubtrack ( blobs )
def deleteSubtrack ( self , key ) :
""" Remove a subtrack.
Return for a potential undo """
self . _subtracks [ key ] . prepareForDeletion ( )
toDelete = self . _subtracks [ key ]
del self . _subtracks [ key ]
return toDelete
#Save / Load / Export
def serialize ( self ) - > dict :
""" Generate Data to save as json """
return {
" name " : self . name ,
" enabled " : self . _enabled ,
}
@classmethod
def instanceFromSerializedData ( cls , parentTrack , serializedData ) :
self = cls . __new__ ( cls )
self . _name = serializedData [ " name " ]
self . _enabled = serializedData [ " enabled " ]
self . parentTrack = parentTrack
self . parentData = parentTrack . parentData
self . _processAfterInit ( )
return self
def export ( self ) - > dict :
return {
" id " : id ( self ) ,
" name " : self . name ,
" index " : self . parentData . tracks . index ( self . parentTrack ) if self . parentTrack in self . parentData . tracks else None , #could be a special track, like the metronome
" cboxPortName " : self . cboxPortName ( ) ,
" cboxMidiOutUuid " : self . cboxMidiOutUuid ,
" enabled " : self . _enabled ,
}
class SfzInstrumentSequencerInterface ( _Interface ) :
""" Like a midi output, only routes to an internal instrument.
This is not a pure sfz sampler , but rather a track that ends in an instrument instead of a
jack midi output . """
def __init__ ( self , parentTrack , name : str , absoluteSfzPath : str ) :
super ( ) . __init__ ( parentTrack , name ) #includes processAfterInit
self . scene = cbox . Document . get_engine ( ) . new_scene ( )
self . scene . clear ( )
self . scene . add_new_instrument_layer ( name , " sampler " ) #"sampler" is the cbox sfz engine
self . scene . status ( ) . layers [ 0 ] . get_instrument ( ) . engine . load_patch_from_string ( 0 , " " , " " , " " ) #fill with null instruments
self . scene . set_enable_default_song_input ( True )
self . instrumentLayer = self . scene . status ( ) . layers [ 0 ] . get_instrument ( )
self . scene . status ( ) . layers [ 0 ] . set_ignore_program_changes ( 1 ) #TODO: ignore different channels. We only want one channel per scene/instrument/port.
newProgramNumber = 1
program = self . instrumentLayer . engine . load_patch_from_file ( newProgramNumber , absoluteSfzPath , name )
self . instrumentLayer . engine . set_patch ( 10 , newProgramNumber ) #from 1. 10 is the channel #TODO: we want this to be on all channels.
#TODO: Metronome is not compatible with current cbox. we need to route midi data from our cbox track explicitely to self.scene, which is not possible right now.
self . calfboxTrack . set_external_output ( " " )
#Metadata
l = f " { cbox . JackIO . status ( ) . client_name } :out_1 "
r = f " { cbox . JackIO . status ( ) . client_name } :out_2 "
self . portnameL = cbox . JackIO . status ( ) . client_name + " : " + name . title ( ) + " -L "
self . portnameR = cbox . JackIO . status ( ) . client_name + " : " + name . title ( ) + " -R "
luuid = cbox . JackIO . create_audio_output ( name . title ( ) + " -L " )
ruuid = cbox . JackIO . create_audio_output ( name . title ( ) + " -R " )
cbox . JackIO . Metadata . set_pretty_name ( self . portnameL , name . title ( ) + " -L " )
cbox . JackIO . Metadata . set_pretty_name ( self . portnameR , name . title ( ) + " -R " )
self . outputMergerRouter = cbox . JackIO . create_audio_output_router ( luuid , ruuid )
self . outputMergerRouter . set_gain ( - 1.0 )
self . instrumentLayer . get_output_slot ( 0 ) . rec_wet . attach ( self . outputMergerRouter ) #output_slot is 0 based and means a pair.
def enable ( self , enabled ) :
if enabled :
self . scene . status ( ) . layers [ 0 ] . set_enable ( True )
else :
self . scene . status ( ) . layers [ 0 ] . set_enable ( False )
self . _enabled = bool ( enabled ) #this is redundant in the SfzInstrument, but the normal midi outs need this. So we stick to the convention.
cbox . Document . get_song ( ) . update_playback ( )
@property
def enabled ( self ) - > bool :
return self . _enabled
class TempoMap ( object ) :
"""
This is a singleton instance in Score . Don ' t subclass.
Main data structure is self . _tempoMap = { positionInTicks : ( bpmAsFloat , timesigUpper , timesigLower ) }
The tempo map is only active if the whole program is JACK Transport Master ( via Cbox ) .
If not we simply follow jack sync .
All values are floats .
TempoMap itself handles this global switch if you set isTransportMaster = True ( it is a property )
For simplicity reasons the tempo map only deals with quarter notes per minute internally .
There are functions to convert to and from a number of other tempo formats .
There are three recommended ways to change the tempo map :
1 ) setTempoMap completely replaces the tempo map with a new supplied one
2 ) If you want just one tempo use the convenience function setQuarterNotesPerMinute .
This will override and delete ( ! ) the current tempo map .
You can retrieve it with getQuarterNotePerMinute .
3 ) set isTransportMaster will trigger a rebuild of the tempo map as a side effect . It does not
change the existing tempo map . Flipping the transport master back will reenable the old tempo Map
If you want to incrementally change the tempo map , which is really not necessary because
changing it completely is a very cheap operation , you can edit the dict _tempoMap directly .
In case you have a complex tempo management yourself , like Laborejo , use it to setTempoMap and
then don ' t worry about save and load. Treat it as a cache that conveniently restores the last
setting after program startup . """
def __init__ ( self , parentData ) :
logger . info ( " Creating empty TempoMap instance " )
self . parentData = parentData
self . _tempoMap = { 0 : ( 120.0 , 4 , 4 ) } # 4/4, 120bpm. will not be used on startup, but is needed if transportMaster is switched on
self . _isTransportMaster = False
self . _processAfterInit ( )
assert not cbox . Document . get_song ( ) . status ( ) . mtis
def _processAfterInit ( self ) :
self . factor = 1.0 # not saved
self . isTransportMaster = self . _isTransportMaster #already triggers cbox settings through @setter.
self . _sanitize ( )
def _updatePlayback ( self ) :
""" A wrapper that not only calls update playback but forces JACK to call its BBT callback,
so it gets the new tempo info even without transport running .
That is a bit of a hack , but it works without disturbing anything too much . """
cbox . Document . get_song ( ) . update_playback ( )
pos = cbox . Transport . status ( ) . pos #can be None on program start
if self . isTransportMaster and not cbox . Transport . status ( ) . playing : #pos can be 0
if pos is None :
#Yes, we destroy the current playback position. But we ARE timebase master, so that is fine.
cbox . Transport . seek_samples ( 0 )
else : #default case
cbox . Transport . seek_samples ( pos )
@property
def isTransportMaster ( self ) - > bool :
return self . _isTransportMaster
@isTransportMaster . setter
def isTransportMaster ( self , value : bool ) :
logger . info ( f " Jack Transport Master status: { value } " )
self . _isTransportMaster = value
if value :
self . _sendToCbox ( ) #reactivate existing tempo map
cbox . JackIO . external_tempo ( False )
cbox . JackIO . transport_mode ( master = True , conditional = False ) #conditional = only attempt to become a master (will fail if there is one already)
else :
self . _clearCboxTempoMap ( ) #clear cbox map but don't touch our own data.
cbox . JackIO . external_tempo ( True )
try :
cbox . JackIO . transport_mode ( master = False )
except Exception : #"Not a current timebase master"
pass
self . _updatePlayback ( )
def _sanitize ( self ) :
""" Inplace modification of self.tempoMap. Remove zeros and convert to float values. """
self . _tempoMap = { int ( key ) : ( float ( value ) , timesigNum , timesigDenom ) for key , ( value , timesigNum , timesigDenom ) in self . _tempoMap . items ( ) if value > 0.0 }
#Don't use the following. Empty tempo maps are allowed, especially in jack transport slave mode. #Instead set a default tempo 120 on init explicitly
#if not self._tempoMap:
#logger.warning("Found invalid tempo map. Forcing to 120 bpm. Please correct manually")
#self._tempoMap = {0, 120.0}
def _clearCboxTempoMap ( self ) :
""" Remove all cbox tempo values by iterating over all of them and set them to None, which is
the secret cbox handshake to delete a tempo change on a specific position .
Keep our own local data intact . """
song = cbox . Document . get_song ( )
for mti in song . status ( ) . mtis : #Creates a new temporary list with newly created objects, safe to iterate and delete.
song . delete_mti ( mti . pos )
self . _updatePlayback ( )
assert not song . status ( ) . mtis , song . status ( ) . mtis
def _sendToCbox ( self ) :
""" Send to cbox """
assert self . isTransportMaster
assert self . _tempoMap
song = cbox . Document . get_song ( )
for pos , ( value , timesigNum , timesigDenom ) in self . _tempoMap . items ( ) :
song . set_mti ( pos = pos , tempo = value * self . factor , timesig_denom = timesigDenom , timesig_num = timesigNum ) #Tempo changes are fine to happen on the same tick as note on.
#song.set_mti(pos=pos, tempo=value * self.factor) #Tempo changes are fine to happen on the same tick as note on.
self . _updatePlayback ( )
def setTempoMap ( self , tempoMap : dict ) :
""" All-in-one function for outside access """
if self . _tempoMap != tempoMap :
self . _tempoMap = tempoMap
self . _sanitize ( )
if self . isTransportMaster : #if not the data will be used later.
self . _clearCboxTempoMap ( ) #keeps our own data so it can be send again.
self . _sendToCbox ( )
def setFactor ( self , factor : float ) :
""" Factor is from 1, not from the current one. """
self . factor = round ( factor , 4 )
self . _sanitize ( )
self . _clearCboxTempoMap ( ) #keeps our own data so it can be send again.
self . _sendToCbox ( ) #uses the factor
def setQuarterNotesPerMinute ( self , quarterNotesPerMinute : float ) :
""" Simple tempo setter. Overrides all other tempo data.
Works in tandem with self . setTimeSignature """
currentValue , timesigNum , timesigDenom = self . _tempoMap [ 0 ]
self . setTempoMap ( { 0 : ( quarterNotesPerMinute , timesigNum , timesigDenom ) } )
def setTimeSignature ( self , timesigNum : int , timesigDenom : int ) :
""" Simple traditional timesig setter. Overrides all other timesig data.
Works in tandem with self . setTimeSignature .
"""
assert timesigNum > 0 , timesigNum
#assert timesigDenom in traditionalNumberToBaseDuration, (timesigDenom, traditionalNumberToBaseDuration) For Laborejo this makes sense, but Patroneo has a fallback option with irregular timesigs: 8 steps in groups of 3 to make a quarter is valid. Results in "8/12"
currentValue , OLD_timesigNum , OLD_timesigDenom = self . _tempoMap [ 0 ]
self . setTempoMap ( { 0 : ( currentValue , timesigNum , timesigDenom ) } )
def getQuarterNotesPerMinute ( self ) - > Union [ float , None ] :
""" This assumes there is only one tempo point """
if self . isTransportMaster :
assert len ( self . _tempoMap ) == 1 , len ( self . _tempoMap )
assert 0 in self . _tempoMap , self . _tempoMap
return self . _tempoMap [ 0 ] [ 0 ] #second [0] is the tuple (tempo, timesig, timesig)
else :
logger . info ( " Requested Quarter Notes per Minute, but we are not transport master " )
return None
#Save / Load / Export
def serialize ( self ) - > dict :
""" Generate Data to save as json """
return {
" isTransportMaster " : self . isTransportMaster ,
" tempoMap " : self . _tempoMap ,
}
@classmethod
def instanceFromSerializedData ( cls , parentData , serializedData ) :
logger . info ( " Loading TempoMap from saved file " )
self = cls . __new__ ( cls )
self . parentData = parentData
self . _tempoMap = serializedData [ " tempoMap " ] #json saves dict-keys as strings. We revert back in sanitize()
self . _isTransportMaster = serializedData [ " isTransportMaster " ]
self . _processAfterInit ( )
return self
def export ( self ) - > dict :
return {
" id " : id ( self ) ,
" isTransportMaster " : self . isTransportMaster ,
" tempoMap " : self . _tempoMap ,
" mtis " : cbox . Document . get_song ( ) . status ( ) . mtis ,
}