#! /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 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 . """ import logging; logger = logging.getLogger(__name__); logger.info("import") #Python Standard Library import os.path from datetime import timedelta #Third Party Modules from 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): 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): """ 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() #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 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 session.data.midiInput.scene.status().layers[0].set_ignore_program_changes(state) assert session.data.midiInput.scene.status().layers[0].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.