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.
687 lines
30 KiB
687 lines
30 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2021, 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
|
|
|
|
#Third Party Modules
|
|
from 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()
|
|
|
|
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)
|
|
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):
|
|
for track in self.tracks:
|
|
if trackId == id(track):
|
|
return track
|
|
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
|
|
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.
|
|
"""
|
|
portlist = cbox.JackIO.get_connected_ports(self.cboxPortName())
|
|
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
|
|
portnameL = f"{cbox.JackIO.status().client_name}:out_1"
|
|
portnameR = f"{cbox.JackIO.status().client_name}:out_2"
|
|
cbox.JackIO.Metadata.set_pretty_name(portnameL, name.title() + "-L")
|
|
cbox.JackIO.Metadata.set_pretty_name(portnameR, name.title() + "-R")
|
|
|
|
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)->float:
|
|
"""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,
|
|
}
|
|
|