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.
409 lines
17 KiB
409 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.listenToAnotherInstanceAttempt)
|
|
|
|
#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 getNsmClients()->list:
|
|
"""Returns a, probably cached, list of dicts that represent all nsm capable clients
|
|
on this system. Including the list of clients the user added themselves"""
|
|
return programDatabase.getNsmClients()
|
|
|
|
def getNsmExecutables()->set:
|
|
"""Cached access fort fast membership tests. Is this program in the PATH?
|
|
This is just to check if an executable is available on this system.
|
|
|
|
The content of the set are executable names, the same as ["agordejoExec"] from getNsmClients elements
|
|
"""
|
|
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"""
|
|
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
|
|
|