Music production session manager https://www.laborejo.org
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.
 
 

416 lines
17 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")
#Standard Library
import datetime
#Third Party
#Our Modules
from engine.start import PATHS
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed.
from engine.jackclient import AgordejoJackClient
from .nsmservercontrol import NsmServerControl
from .watcher import Watcher
from .findprograms import programDatabase
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):
global jackClient
self.message = []
#Session Management
self.sessionOpenReady = []
self.sessionOpenLoading = []
self.sessionClosed = []
self.sessionsChanged = [] #update in the file structure. redraw list of sessions.
self.sessionLocked = [] # incremental update. Sends the name of the session project and a bool if locked
self.sessionFileChanged = [] #incremental update. Reports the session name, not the session file
self.clientStatusChanged = [] #every status including GUI and dirty
self.singleInstanceActivateWindow = [] #this is for the single-instance feature. Show the GUI window and activate it when this signal comes.
self.dataClientNamesChanged = []
self.dataClientDescriptionChanged = []
self.dataClientTimelineMaximumDurationChanged = [] #in minutes. this is purely a GUI construct. the jackClient knows no limit!.
#JackClient Callbacks. For the GUI they are mirrored here. These are mutable, shared lists.
#The callback functions are in jackClient directly and api functions can call them.
self.setPlaybackSeconds = jackClient.callback_setPlaybackSeconds
def _dataClientNamesChanged(self, data):
"""If there is a dataclient in the session it will allow us to read and write metadata.
The GUI instructs us to send a write instruction over OSC, we wait for the OSC answer
and forward that to the GUI.
If the dataClient joins the session it will trigger an unrequested callback (from the GUIs
perspectvive). If the client leaves the callback will send a single None. This is a sign
for the GUI to reset all data to the nsmd state. We do not mix nsmd data and dataClient. A
dataclient can join and leave at every time, we keep the GUI informed. """
for func in self.dataClientNamesChanged:
func(data)
def _dataClientDescriptionChanged(self, data):
"""see _dataClientNamesChanged.
In short: str for data, None if nsm-data leaves session"""
for func in self.dataClientDescriptionChanged:
func(data)
def _dataClientTimelineMaximumDurationChanged(self, minutes:int):
"""
This callback is still used, even if nsm-data is not in the session.
It will then purely be a roundtrip from gui widget -> api -> gui-callback without
saving anything.
For compatibility reasons it will still send a "None" when nsm-data leaves the session.
The GUI can then just continue with the current value. It just
means that the values will not be saved in the session.
"""
for func in self.dataClientTimelineMaximumDurationChanged:
func(minutes)
def _singleInstanceActivateWindow(self):
for func in self.singleInstanceActivateWindow:
func()
def _sessionOpenReady(self, nsmSessionExportDict):
"""A project got opened, most likely by ourselves, but also by another party.
This will also fire if we start and detect that a session is already open and running.
"""
for func in self.sessionOpenReady:
func(nsmSessionExportDict)
def _sessionOpenLoading(self, nsmSessionExportDict):
"""
A session begins loading. Show a spinning clock or so...
"""
for func in self.sessionOpenLoading:
func(nsmSessionExportDict)
def _sessionClosed(self):
"""The current session got closed. Present a list of sessions to choose from again.
This is also send at GUI start if there is no session open presently."""
for func in self.sessionClosed:
func()
def _sessionsChanged(self):
"""The project list changed.
This can happen through internal changes like deletion or duplication
but also through external changes through a filemanager.
Always sends a full update of everything, with no indication of what changed."""
listOfProjectDicts = nsmServerControl.exportSessionsAsDicts()
for func in self.sessionsChanged:
func(listOfProjectDicts)
return listOfProjectDicts
def _sessionLocked(self, name:str, status:bool):
"""Called by the Watcher through the event loop
Name is "nsmSessionName" from _nsmServerControl.exportSessionsAsDicts
Sends True if project is locked"""
for func in self.sessionLocked:
func(name, status)
def _sessionFileChanged(self, name:str, timestamp:str):
"""
This happens everytime the session gets saved.
Called by the Watcher through the event loop.
Name is "nsmSessionName" from _nsmServerControl.exportSessionsAsDicts.
timestamp has same format as nsmServerControl.exportSessionsAsDicts"""
for func in self.sessionFileChanged:
func(name, timestamp)
def _clientStatusChanged(self, clientInfoDict:dict):
"""A single function for all client changes. Adding and deletion is also included.
GUI hide/show and dirty is also here.
A GUI needs to check if it already knows the clientId or not."""
for func in self.clientStatusChanged:
func(clientInfoDict)
def startEngine():
logger.info("Start Engine")
global eventLoop
assert eventLoop
global nsmServerControl
nsmServerControl = NsmServerControl(
sessionOpenReadyHook=callbacks._sessionOpenReady,
sessionOpenLoadingHook=callbacks._sessionOpenLoading,
sessionClosedHook=callbacks._sessionClosed,
clientStatusHook=callbacks._clientStatusChanged,
singleInstanceActivateWindowHook=callbacks._singleInstanceActivateWindow,
dataClientNamesHook=callbacks._dataClientNamesChanged,
dataClientDescriptionHook=callbacks._dataClientDescriptionChanged,
dataClientTimelineMaximumDurationChangedHook=callbacks._dataClientTimelineMaximumDurationChanged,
parameterNsmOSCUrl=PATHS["url"],
sessionRoot=PATHS["sessionRoot"],
startupSession=PATHS["startupSession"],
)
#Watch session tree for changes.
global sessionWatcher
sessionWatcher = Watcher(nsmServerControl)
sessionWatcher.timeStampHook = callbacks._sessionFileChanged
sessionWatcher.lockFileHook = callbacks._sessionLocked
sessionWatcher.sessionsChangedHook = callbacks._sessionsChanged #This is the main callback that informs of new or updated sessions
callbacks.sessionClosed.append(sessionWatcher.resume) #Watcher only active in "Choose a session mode"
callbacks.sessionOpenReady.append(sessionWatcher.suspend)
eventLoop.slowConnect(sessionWatcher.process)
#Start Event Loop Processing
eventLoop.fastConnect(nsmServerControl.process)
eventLoop.fastConnect(jackClient._setPlaybackSeconds)
eventLoop.slowConnect(nsmServerControl.processSingleInstance)
#Send initial data
#The decision if we are already in a session on startup or in "choose a session mode" is handled by callbacks
#This is not to actually gather the data, but only to inform the GUI.
logger.info("Send initial cached data to GUI.")
callbacks._sessionsChanged() #send session list
c = currentSession() #sessionName
if c:
callbacks._sessionOpenReady(nsmServerControl.sessionAsDict(c))
#Send client list. This is only necessary when attaching to an URL or using NSM-URL Env var
#When we do --load-session we receive client updates live.
#But this little redundancy doesn't hurt, we just sent them. Better safe than sorry.
for clientId, clientDict in nsmServerControl.internalState["clients"].items():
callbacks._clientStatusChanged(clientDict)
else:
callbacks._sessionClosed() #explicit is better than implicit. Otherwise a GUI might start in the wrong state
#nsmServerControl blocks until it has a connection to nsmd. That means at this point we are ready to send commands.
#Until we return from startEngine a GUI will also not create its mainwindow.
logger.info("Engine start complete")
#Info
def ourOwnServer():
"""Report if we started nsmd on our own. If not we will not kill it when we quit"""
return nsmServerControl.ourOwnServer
def sessionRoot():
return nsmServerControl.sessionRoot
def currentSession():
return nsmServerControl.internalState["currentSession"]
def sessionList()->list:
"""Updates the list each call. Use only this from a GUI for active query.
Otherwise sessionRemove and sessionCopy will not have updated the list"""
r = nsmServerControl.exportSessionsAsDicts()
return [s["nsmSessionName"] for s in r]
def requestSessionList():
"""For the rare occasions where that is needed"""
callbacks._sessionsChanged() #send session list
def buildSystemPrograms(progressHook=None):
"""Build a list of dicts with the .desktop files (or similar) of all NSM compatible programs
present on the system"""
programDatabase.build(progressHook)
def systemProgramsSetWhitelist(executableNames:tuple):
"""will replace the current list"""
programDatabase.userWhitelist = tuple(executableNames)
#Needs rebuild through the GUI. We have no callback for this.
def systemProgramsSetBlacklist(executableNames:tuple):
"""will replace the current list"""
programDatabase.userBlacklist = tuple(executableNames)
#Needs rebuild through the GUI. We have no callback for this.
def getCache()->dict:
"""Returns the cached database from buildProgramDatabase. No automatic update. Empty on program
start
db = {
"programs" : list of dicts
iconPaths : list of strings
}
"""
return programDatabase.getCache()
def setCache(cache:dict):
programDatabase.setCache(cache)
def getNsmExecutables()->set:
"""Cached access fort fast membership tests. Is this program in the PATH?"""
return programDatabase.nsmExecutables
def getUnfilteredExecutables()->list:
"""Return a list of unique names without paths or directories of all exectuables in users $PATH.
This is intended for a program starter prompt. GUI needs to supply tab completition or search
itself"""
programDatabase.unfilteredExecutables = programDatabase.buildCache_unfilteredExecutables() #quick call
return programDatabase.unfilteredExecutables
#Session Control
#No project running
#There is no callback for _sessionsChanged because we poll that in the event loop.
def sessionNewTimestamped():
"""convenience function. Create a new session without requiring a name and add
suggested infrastructure clients"""
nsmExecutables = getNsmExecutables() #type set, cached, very fast.
connectionSaver = METADATA["preferredClients"]["data"]
dataMeta = METADATA["preferredClients"]["connections"]
startclients = []
if connectionSaver in nsmExecutables:
startclients.append(connectionSaver)
if dataMeta in nsmExecutables:
startclients.append(dataMeta)
#now = datetime.datetime.now().replace(second=0, microsecond=0).isoformat()[:-3]
now = datetime.datetime.now().replace(microsecond=0).isoformat()
name = now
sessionNew(name, startclients)
def sessionNew(newName:str, startClients:list=[]):
nsmServerControl.new(newName, startClients)
def sessionRename(nsmSessionName:str, newName:str):
"""only for non-open sessions"""
nsmServerControl.renameSession(nsmSessionName, newName)
def sessionCopy(nsmSessionName:str, newName:str, progressHook=None):
"""Create a copy of the session. Removes the lockfile, if any.
Has some safeguards inside so it will not crash.
If progressHook is provided (e.g. by a GUI) it will be called at regular intervals
to inform of the copy process, or at least that it is still running.
"""
nsmServerControl.copySession(nsmSessionName, newName, progressHook)
def sessionOpen(nsmSessionName:str):
"""Saves the current session and loads a different existing session."""
nsmServerControl.open(nsmSessionName)
def sessionQuery(nsmSessionName:str):
"""For the occasional out-of-order information query.
Exports a single session project in the format of nsmServerControl.exportSessionsAsDicts"""
return nsmServerControl.sessionAsDict(nsmSessionName)
def sessionDelete(nsmSessionName:str):
nsmServerControl.deleteSession(nsmSessionName)
#While Project is open
def sessionSave():
"""Saves the current session."""
nsmServerControl.save()
def sessionClose(blocking=False):
"""Saves and closes the current session."""
nsmServerControl.close(blocking)
def sessionAbort(blocking=False):
"""Close without saving the current session."""
nsmServerControl.abort(blocking)
def sessionSaveAs(nsmSessionName:str):
"""Duplicate in NSM terms. Make a copy, close the current one, open the new one.
However, it will NOT send the session clossed signal, just a session changed one."""
nsmServerControl.duplicate(nsmSessionName)
def setDescription(text:str):
nsmServerControl.setDescription(text)
def setTimelineMaximumDuration(minutes:int):
nsmServerControl.setTimelineMaximumDuration(int(minutes))
#Client Handling
def clientAdd(executableName):
nsmServerControl.clientAdd(executableName)
#status hook triggers clientStatusChanged callback
def clientStop(clientId:str):
nsmServerControl.clientStop(clientId)
def clientResume(clientId:str):
"""Opposite of clientStop"""
nsmServerControl.clientResume(clientId)
def clientRemove(clientId:str):
"""Client must be already stopped! We will do that without further question.
Remove from the session. Will not delete the save-files, but make them inaccesible"""
nsmServerControl.clientRemove(clientId)
def clientSave(clientId:str):
"""Saves only the given client"""
nsmServerControl.clientSave(clientId)
def clientToggleVisible(clientId:str):
"""Works only if client announced itself with this feature"""
nsmServerControl.clientToggleVisible(clientId)
def clientHideAll():
nsmServerControl.allClientsHide()
def clientShowAll():
nsmServerControl.allClientsShow()
def clientNameOverride(clientId:str, name:str):
"""An agordejo-specific function that requires the client nsm-data in the session.
If nsm-data is not present this function will write nothing, not touch any data.
It will still send a callback to revert any GUI changes back to the original name.
We accept empty string as a name to remove the name override
"""
nsmServerControl.clientNameOverride(clientId, name)
def executableInSession(executable:str)->dict:
"""Returns None if no client with this executable is in the session,
else returns a dict with its export-data.
If multiple clients with this exe are in the session only one is returned, whatever Python
thinks is good"""
for clientId, dic in nsmServerControl.internalState["clients"].items():
if executable == dic["executable"]:
return dic
else:
return None
#Global Datastructures, set in startEngine
nsmServerControl = None
eventLoop = None
sessionWatcher = None
jackClient = AgordejoJackClient() #Create before callbacks
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