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.
507 lines
20 KiB
507 lines
20 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")
|
|
|
|
|
|
#Python Standard Library
|
|
import os.path
|
|
from datetime import timedelta
|
|
|
|
#Third Party Modules
|
|
from template.calfbox import cbox
|
|
|
|
#Our own template modules
|
|
from .session import Session
|
|
from .duration import DB, DL, D1, D2, D4, D8, D16, D32, D64, D128, D256, D512, D1024, jackBBTicksToDuration
|
|
from ..helper import nothing
|
|
|
|
class Callbacks(object):
|
|
"""GUI methods register themselves here.
|
|
These methods get called by us, the engine.
|
|
|
|
None of these methods produce any return value.
|
|
|
|
The lists may be unordered.
|
|
|
|
We need the lists for audio feedbacks in parallel to GUI updates.
|
|
Or whatever parallel representations we run."""
|
|
|
|
def __init__(self):
|
|
self.debugChanged = [] #only for testing and debug.
|
|
self.setPlaybackTicks = []
|
|
self.playbackStatusChanged = []
|
|
self.bbtStatusChanged = []
|
|
self.barBeatTempo = []
|
|
self.clock = []
|
|
self.historyChanged = []
|
|
self.historySequenceStarted = []
|
|
self.historySequenceStopped = []
|
|
self.message = []
|
|
|
|
#Live Midi Recording
|
|
self.recordingModeChanged = []
|
|
|
|
#Sequencer
|
|
self.numberOfTracksChanged = []
|
|
self.metronomeChanged = []
|
|
|
|
#Sf2 Sampler
|
|
self.soundfontChanged = []
|
|
self.channelChanged = []
|
|
self.channelActivity = []
|
|
self.ignoreProgramChangesChanged = []
|
|
|
|
#Not callbacks
|
|
self._rememberPlaybackStatus = None #Once set it will be True or False
|
|
self._rememberBBT = None #Will be a dict
|
|
self._rememberBarBeatTempo = None #Specialized case of BBT
|
|
|
|
|
|
def _dataChanged(self):
|
|
"""Only called from within the callbacks or template api.
|
|
This is about data the user cares about. In other words this is the indicator if you need
|
|
to save again.
|
|
|
|
Insert, delete edit are real data changes. Cursor movement or playback ticks are not."""
|
|
session.nsmClient.announceSaveStatus(False)
|
|
self._historyChanged()
|
|
|
|
|
|
def _historyChanged(self):
|
|
"""processQueue
|
|
Only called from within the callbacks.
|
|
|
|
sends two lists of strings.
|
|
the first is the undoHistory, the last added item is [-1]. We can show that to a user to
|
|
indicate what the next undo will do.
|
|
|
|
the second is redoHistory, same as undo: [-1] shows the next redo action."""
|
|
undoHistory, redoHistory = session.history.asList()
|
|
for func in self.historyChanged:
|
|
func(undoHistory, redoHistory)
|
|
|
|
def _historySequenceStarted(self):
|
|
"""sends a signal when a sequence of high level api functions will be executed next.
|
|
Also valid for their undo sequences.
|
|
A GUI has the chance to disable immediate drawing, e.g. qt Graphics Scene could stop
|
|
scene updates and allow all callbacks to come in first.
|
|
historySequenceStopped will be sent when the sequence is over and a GUI could reactivate
|
|
drawing to have the buffered changes take effect.
|
|
This signal is automatically sent by the history sequence context"""
|
|
for func in self.historySequenceStarted:
|
|
func()
|
|
|
|
def _historySequenceStopped(self):
|
|
"""see _historySequenceStarted"""
|
|
for func in self.historySequenceStopped:
|
|
func()
|
|
|
|
def _message(self, title, text):
|
|
"""Send a message of any kind to get displayed.
|
|
Enables an api function to display a message via the GUI.
|
|
Does _not_ support translations, therefore ued for errors mostly"""
|
|
for func in self.message:
|
|
func(title, text)
|
|
|
|
def _debugChanged(self):
|
|
for func in self.debugChanged:
|
|
func()
|
|
self._dataChanged() #includes _historyChanged
|
|
|
|
|
|
def _setPlaybackTicks(self):
|
|
"""This gets called very very often (~60 times per second).
|
|
Any connected function needs to watch closely
|
|
for performance issues"""
|
|
ppqn = cbox.Transport.status().pos_ppqn
|
|
status = playbackStatus()
|
|
for func in self.setPlaybackTicks:
|
|
func(ppqn, status)
|
|
|
|
def _playbackStatusChanged(self):
|
|
"""Returns a bool if the playback is running.
|
|
Under rare circumstances it may send the same status in a row, which means
|
|
you actually need to check the result and not only toggle as a response.
|
|
|
|
This callback cannot be called manually. Instead it will be called automatically to make
|
|
it possible to react to external jack transport changes.
|
|
|
|
This is deprecated. Append to _checkPlaybackStatusAndSendSignal which is checked by the
|
|
event loop.
|
|
"""
|
|
raise NotImplementedError("this function was deprecated. use _checkPlaybackStatusAndSendSignal")
|
|
|
|
pass #only keep for the docstring and to keep the pattern.
|
|
|
|
def _checkPlaybackStatusAndSendSignal(self):
|
|
"""Added to the event loop.
|
|
We don'T have a jack callback to inform us of this so we drive our own polling system
|
|
which in turn triggers our own callback, when needed."""
|
|
status = playbackStatus()
|
|
if not self._rememberPlaybackStatus == status:
|
|
self._rememberPlaybackStatus = status
|
|
for func in self.playbackStatusChanged:
|
|
func(status)
|
|
|
|
def _checkBBTAndSendSignal(self):
|
|
"""Added to the event loop.
|
|
We don'T have a jack callback to inform us of this so we drive our own polling system
|
|
which in turn triggers our own callback, when needed.
|
|
|
|
We are interested in:
|
|
bar
|
|
beat #first index is 1
|
|
tick
|
|
bar_start_tick
|
|
beats_per_bar [4.0]
|
|
beat_type [4.0]
|
|
ticks_per_beat [960.0] #JACK ticks, not cbox.
|
|
beats_per_minute [120.0]
|
|
|
|
int bar is the current bar.
|
|
int beat current beat-within-bar
|
|
int tick current tick-within-beat
|
|
double bar_start_tick number of ticks that have elapsed between frame 0 and the first beat of the current measure.
|
|
"""
|
|
data = cbox.JackIO.jack_transport_position() #this includes a lot of everchanging data. If no jack-master client set /bar and the others they will simply not be in the list
|
|
t = (data.beats_per_bar, data.ticks_per_beat)
|
|
if not self._rememberBBT == t: #new situation, but not just frame position update
|
|
self._rememberBBT = t
|
|
export = {}
|
|
if data.beats_per_bar:
|
|
offset = (data.beat-1) * data.ticks_per_beat + data.tick #if timing is good this is the same as data.tick because beat is 1.
|
|
offset = jackBBTicksToDuration(data.beat_type, offset, data.ticks_per_beat)
|
|
export["nominator"] = data.beats_per_bar
|
|
export["denominator"] = jackBBTicksToDuration(data.beat_type, data.ticks_per_beat, data.ticks_per_beat) #the middle one is the changing one we are interested in
|
|
export["measureInTicks"] = export["nominator"] * export["denominator"]
|
|
export["offsetToMeasureBeginning"] = offset
|
|
#export["tickposition"] = cbox.Transport.status().pos_ppqn #this is a different position than our current one because it takes a few cycles and ticks to calculate
|
|
export["tickposition"] = cbox.Transport.samples_to_ppqn(data.frame)
|
|
for func in self.bbtStatusChanged:
|
|
func(export)
|
|
|
|
#Send bar beats tempo, for displays
|
|
#TODO: broken
|
|
"""
|
|
bbtExport = {}
|
|
if data.beat and not self._rememberBarBeatTempo == data.beat:
|
|
|
|
bbtExport["timesig"] = f"{int(data.beats_per_bar)}/{int(data.beat_type)}" #for displays
|
|
bbtExport["beat"] = data.beat #index from 1
|
|
bbtExport["tempo"] = int(data.beats_per_minute)
|
|
bbtExport["bar"] = int(data.bar)
|
|
self._rememberBarBeatTempo = data.beat #this should be enough inertia to not fire every 100ms
|
|
for func in self.barBeatTempo:
|
|
func(bbtExport)
|
|
elif not data.beat:
|
|
for func in self.barBeatTempo:
|
|
func(bbtExport)
|
|
self._rememberBarBeatTempo = data.beat
|
|
"""
|
|
|
|
clock = str(timedelta(seconds=data.frame / data.frame_rate))
|
|
for func in self.clock:
|
|
func(clock)
|
|
|
|
|
|
#Live Midi Recording
|
|
def _recordingModeChanged(self):
|
|
if session.recordingEnabled:
|
|
session.nsmClient.changeLabel("Recording")
|
|
else:
|
|
session.nsmClient.changeLabel("")
|
|
for func in self.recordingModeChanged:
|
|
func(session.recordingEnabled)
|
|
|
|
#Sequencer
|
|
def _numberOfTracksChanged(self):
|
|
"""New track, delete track, reorder
|
|
Sent the current track order as list of ids, combined with their structure.
|
|
This is also used when tracks get created or deleted, also on initial load.
|
|
"""
|
|
session.data.updateJackMetadataSorting()
|
|
lst = [track.export() for track in session.data.tracks]
|
|
for func in self.numberOfTracksChanged:
|
|
func(lst)
|
|
self._dataChanged() #includes _historyChanged
|
|
|
|
|
|
def _metronomeChanged(self):
|
|
"""returns a dictionary with meta data such as the mute-state and the track name"""
|
|
exportDict = session.data.metronome.export()
|
|
for func in self.metronomeChanged:
|
|
func(exportDict)
|
|
|
|
#Sf2 Sampler
|
|
def _soundfontChanged(self):
|
|
"""User loads a new soundfont or on load. Resets everything."""
|
|
exportDict = session.data.export()
|
|
session.data.updateAllChannelJackMetadaPrettyname()
|
|
session.nsmClient.changeLabel(exportDict["name"])
|
|
if exportDict:
|
|
for func in self.soundfontChanged:
|
|
func(exportDict)
|
|
|
|
def _channelChanged(self, channel):
|
|
"""A single channel changed its parameters. The soundfont stays the same."""
|
|
exportDict = session.data.exportChannel(channel)
|
|
session.data.updateChannelAudioJackMetadaPrettyname(channel)
|
|
session.data.updateChannelMidiInJackMetadaPrettyname(channel)
|
|
for func in self.channelChanged:
|
|
func(channel, exportDict)
|
|
|
|
def _ignoreProgramChangesChanged(self):
|
|
#TODO: this only checks the first layer. There might be a second one as well. fine.jpg
|
|
state = session.data.midiInput.scene.status().layers[0].status().ignore_program_changes
|
|
for func in self.ignoreProgramChangesChanged:
|
|
func(state)
|
|
|
|
def _channelActivity(self, channel):
|
|
"""send all note on to the GUI"""
|
|
for func in self.channelActivity:
|
|
func(channel)
|
|
|
|
|
|
def startEngine(nsmClient, additionalData:dict={}):
|
|
"""
|
|
This function gets called after initializing the GUI, calfbox
|
|
and loading saved data from a file.
|
|
|
|
It gets called by client applications before their own startEngine.
|
|
|
|
Stopping the engine is done via pythons atexit in the session.
|
|
"""
|
|
logger.info("Starting template api engine")
|
|
assert session
|
|
assert callbacks
|
|
|
|
session.nsmClient = nsmClient
|
|
session.eventLoop.fastConnect(callbacks._checkPlaybackStatusAndSendSignal)
|
|
session.eventLoop.fastConnect(callbacks._setPlaybackTicks)
|
|
session.eventLoop.fastConnect(cbox.get_new_events) #global cbox.get_new_events does not eat dynamic midi port events.
|
|
session.eventLoop.slowConnect(callbacks._checkBBTAndSendSignal)
|
|
#session.eventLoop.slowConnect(lambda: print(cbox.Transport.status().tempo))
|
|
#asession.eventLoop.slowConnect(lambda: print(cbox.Transport.status()))
|
|
|
|
cbox.Document.get_song().update_playback()
|
|
|
|
callbacks._recordingModeChanged() #recording mode is in the save file.
|
|
callbacks._historyChanged() #send initial undo status to the GUI, which will probably deactivate its undo/redo menu because it is empty.
|
|
logger.info("Template api engine started")
|
|
|
|
|
|
def isStandaloneMode():
|
|
return session.standaloneMode
|
|
|
|
def _deprecated_updatePlayback():
|
|
"""The only place in the program to update the cbox playback besides startEngine.
|
|
We only need to update it after a user action, which always goes through the api.
|
|
Even if triggered through a midi in or other command.
|
|
Hence there is no need to update playback in the session or directly from the GUI."""
|
|
#if session.nsmClient.cachedSaveStatus = False: #dirty #TODO: wait for cbox optimisations. The right place to cache and check if an update is necessary is in cbox, not here.
|
|
cbox.Document.get_song().update_playback()
|
|
|
|
def save():
|
|
"""Saves the file in place. This is mostly here for psychological reasons. Users like to hit
|
|
Ctrl+S from muscle memory.
|
|
But it can also be used if we run with fake NSM. In any case, it does not accept paths"""
|
|
session.nsmClient.serverSendSaveToSelf()
|
|
|
|
def undo():
|
|
"""No callbacks need to be called. Undo is done via a complementary
|
|
function, already defined, which has all the callbacks in it."""
|
|
session.history.undo()
|
|
callbacks._dataChanged() #this is in most of the functions already but high-level scripts that use the history.sequence context do not trigger this and get no redo without.
|
|
|
|
def redo():
|
|
"""revert undo if nothing new has happened so far.
|
|
see undo"""
|
|
session.history.redo()
|
|
callbacks._dataChanged() #this is in most of the functions already but high-level scripts that use the history.sequence context do not trigger this and get no redo without.
|
|
|
|
|
|
def getUndoLists():
|
|
#undoHistory, redoHistory = session.history.asList()
|
|
return session.history.asList()
|
|
|
|
def getMidiInputNameAndUuid():
|
|
return session.data.getMidiInputNameAndUuid() #tuple name:str, uuid
|
|
|
|
#Calfbox Sequencer Controls
|
|
def playbackStatus()->bool:
|
|
#status = "[Running]" if cbox.Transport.status().playing else "[Stopped]" #it is not that simple.
|
|
cboxStatus = cbox.Transport.status().playing
|
|
if cboxStatus == 1:
|
|
#status = "[Running]"
|
|
return True
|
|
elif cboxStatus == 0:
|
|
#status = "[Stopped]"
|
|
return False
|
|
elif cboxStatus == 2:
|
|
#status = "[Stopping]"
|
|
return False
|
|
elif cboxStatus is None:
|
|
#status = "[Uninitialized]"
|
|
return False
|
|
elif cboxStatus == "":
|
|
#running with cbox dummy module
|
|
return False
|
|
else:
|
|
raise ValueError("Unknown playback status: {}".format(cboxStatus))
|
|
|
|
def playPause():
|
|
"""There are no internal callback to start and stop playback.
|
|
The api, or the session, do not call that.
|
|
Playback can be started externally via jack transport.
|
|
We use the jack transport callbacks instead and trigger our own callbacks directly from them,
|
|
in the callback class above"""
|
|
if playbackStatus():
|
|
cbox.Transport.stop()
|
|
else:
|
|
cbox.Transport.play()
|
|
#It takes a few ms for playbackStatus to catch up. If you call it here again it will not be updated.
|
|
|
|
def stop():
|
|
cbox.Transport.stop()
|
|
|
|
def getPlaybackTicks()->int:
|
|
return cbox.Transport.status().pos_ppqn
|
|
|
|
def seek(value):
|
|
if value < 0:
|
|
value = 0
|
|
cbox.Transport.seek_ppqn(value)
|
|
|
|
def toStart():
|
|
seek(0)
|
|
|
|
def playFrom(ticks):
|
|
seek(ticks)
|
|
if not playbackStatus():
|
|
cbox.Transport.play()
|
|
|
|
def playFromStart():
|
|
toStart()
|
|
if not playbackStatus():
|
|
cbox.Transport.play()
|
|
|
|
def toggleRecordingMode():
|
|
session.recordingEnabled = not session.recordingEnabled
|
|
callbacks._recordingModeChanged()
|
|
|
|
# Sequencer Metronome
|
|
def setMetronome(data, label):
|
|
session.data.metronome.generate(data, label)
|
|
callbacks._metronomeChanged()
|
|
|
|
def enableMetronome(value):
|
|
session.data.metronome.setEnabled(value) #has side effects
|
|
callbacks._metronomeChanged()
|
|
|
|
def isMetronomeEnabled():
|
|
return session.data.metronome.enabled
|
|
|
|
def toggleMetronome():
|
|
enableMetronome(not session.data.metronome.enabled) #handles callback etc.
|
|
|
|
|
|
#Sf2 Sampler
|
|
def loadSoundfont(filePath):
|
|
"""User callable function. Load from saved state is done directly in the session with callbacks
|
|
in startEngine
|
|
The filePath MUST be in our session dir.
|
|
"""
|
|
filePathInOurSession = os.path.commonprefix([filePath, session.nsmClient.ourPath]) == session.nsmClient.ourPath
|
|
if not filePathInOurSession:
|
|
raise Exception("api loadSoundfont tried to load .sf2 from outside session dir. Forbidden")
|
|
|
|
success, errormessage = session.data.loadSoundfont(filePath)
|
|
|
|
if success:
|
|
callbacks._soundfontChanged()
|
|
session.history.clear()
|
|
callbacks._historyChanged()
|
|
callbacks._dataChanged()
|
|
else:
|
|
callbacks._message("Load Soundfont Error", errormessage)
|
|
|
|
return success
|
|
|
|
def setIgnoreProgramAndBankChanges(state):
|
|
state = bool(state)
|
|
#there is no session wrapper function. we use cbox directly. Save file and callbacks will fetch the current value on its own
|
|
for layer in session.data.midiInput.scene.status().layers: #midi in[0] and real time midi thru[1]
|
|
layer.set_ignore_program_changes(state)
|
|
assert layer.status().ignore_program_changes == state
|
|
callbacks._ignoreProgramChangesChanged()
|
|
callbacks._dataChanged()
|
|
|
|
|
|
def setPatch(channel, bank, program):
|
|
if not 1 <= channel <= 16:
|
|
raise ValueError (f"Channel must be a number between 1 and 16. Yours: {channel}")
|
|
#Bank is split into CC0 and CC32. That makes it a 14bit value (2**14 or 128 * 128) = 16384
|
|
if not 0 <= bank <= 16384:
|
|
raise ValueError (f"Program must be a number between 0 and 16384. Yours: {bank}")
|
|
if not 0 <= program <= 127:
|
|
raise ValueError (f"Program must be a number between 0 and 127. Yours: {program}")
|
|
|
|
session.data.setPatch(channel, bank, program)
|
|
callbacks._channelChanged(channel)
|
|
callbacks._dataChanged()
|
|
|
|
#Debug, Test and Template Functions
|
|
class TestValues(object):
|
|
value = 0
|
|
|
|
def history_test_change():
|
|
"""
|
|
We simulate a function that gets its value from context.
|
|
Here it is random, but it may be the cursor position in a real program."""
|
|
from random import randint
|
|
value = 0
|
|
while value == 0:
|
|
value = randint(-10,10)
|
|
|
|
if value > 0:
|
|
session.history.setterWithUndo(TestValues, "value", TestValues.value + value, "Increase Value", callback=callbacks._debugChanged) #callback includes dataChanged which inlucdes historyChanged
|
|
else:
|
|
session.history.setterWithUndo(TestValues, "value", TestValues.value + value, "Decrease Value", callback=callbacks._debugChanged) #callback includes dataChanged which inlucdes historyChanged
|
|
|
|
def history_test_undoSequence():
|
|
with session.history.sequence("Change Value Multiple Times"):
|
|
history_test_change()
|
|
history_test_change()
|
|
history_test_change()
|
|
history_test_change()
|
|
callbacks._historyChanged()
|
|
|
|
|
|
#Module Level Data
|
|
callbacks = Callbacks() #This needs to be defined before startEngine() so a GUI can register its callbacks. The UI will then startEngine and wait to reveice the initial round of callbacks
|
|
session = Session()
|
|
|
|
session.history.apiCallback_historySequenceStarted = callbacks._historySequenceStarted
|
|
session.history.apiCallback_historySequenceStopped = callbacks._historySequenceStopped
|
|
|
|
#Import complete. Now the parent module, like a gui, will call startEngine() and provide an event loop.
|
|
|