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.
 
 

339 lines
15 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 ),
more specifically its template base application.
The Template Base 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("nsm-data"); logger.info("import")
URL="https://www.laborejo.org/agordejo/nsm-data"
HARD_LIMIT = 512 # no single message longer than this
VERSION= 1.1
#In case the user tries to run this standalone.
import argparse
parser = argparse.ArgumentParser(description="nsm-data is a module for Agordejo. It only communicates over OSC in an NSM-Session and has no standalone functionality.")
parser.add_argument("-v", "--version", action='version', version=str(VERSION))
args = parser.parse_args()
import json
import pathlib
from time import sleep
from sys import exit as sysexit
from nsmclient import NSMClient
from nsmclient import NSMNotRunningError
def chunkstring(string):
return [string[0+i:HARD_LIMIT+i] for i in range(0, len(string), HARD_LIMIT)]
class DataClient(object):
"""
Keys are strings,
While nsmd OSC support int, str and float we use json exclusively.
We expect a json string and will parse it here.
All message consist of two arguments maximum: a key and, if a create-function, a json string.
Rule: all client-keys are send as strings, even in replies. All client-values are send as
json-string, even if originally just a string.
Description is a multi-part message, a string.
DataClient will register itself as Data-Storage. All other communication is done via osc.
In theory every application can read and write us (like a book!)
We listen to OSC paths and reply to the sender, which must give its address explicitly.
/agordejo/datastorage/readall s:request-host i:request-port #Request all data
/agordejo/datastorage/read s:key s:request-host i:request-port #Request one value
The write functions have no reply. They will print out to stdout/err but not send an error
message back.
/agordejo/datastorage/create s:key any:value #Write/Create one value
/agordejo/datastorage/update s:kecy any:value #Update a value, but only if it exists
/agordejo/datastorage/delete s:key #Remove a key/value completely
"""
def __init__(self):
self.data = None #Dict. created in openOrNewCallbackFunction, saved as json
self.absoluteJsonFilePath = None #pathlib.Path set by openOrNewCallbackFunction
self._descriptionStringArray = {"identifier":None} #int:str
self._descriptionId = None
self.nsmClient = NSMClient(prettyName = "Data-Storage", #will raise an error and exit if this example is not run from NSM.
saveCallback = self.saveCallbackFunction,
openOrNewCallback = self.openOrNewCallbackFunction,
supportsSaveStatus = True, # Change this to True if your program announces it's save status to NSM
exitProgramCallback = self.exitCallbackFunction,
broadcastCallback = None,
hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False)
showGUICallback = None, #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True)
sessionIsLoadedCallback = self.sessionIsLoadedCallback, #no parametersd
loggingLevel = "error", #"info" for development or debugging, "error" for production. default is error.
)
#Add custom callbacks. They all receive _IncomingMessage(data)
self.nsmClient.reactions["/agordejo/datastorage/setclientoverridename"] = self.setClientOverrideName
self.nsmClient.reactions["/agordejo/datastorage/getclientoverridename"] = self.getClientOverrideName
self.nsmClient.reactions["/agordejo/datastorage/getall"] = self.getAll
self.nsmClient.reactions["/agordejo/datastorage/getdescription"] = self.getDescription
self.nsmClient.reactions["/agordejo/datastorage/setdescription"] = self.setDescription
self.nsmClient.reactions["/agordejo/datastorage/gettimelinemaximum"] = self.getTimelineMaximum
self.nsmClient.reactions["/agordejo/datastorage/settimelinemaximum"] = self.setTimelineMaximum
#self.nsmClient.reactions["/agordejo/datastorage/read"] = self.reactRead #generic key/value storage
#self.nsmClient.reactions["/agordejo/datastorage/readall"] = self.reactReadAll
#self.nsmClient.reactions["/agordejo/datastorage/create"] = self.reactCreate
#self.nsmClient.reactions["/agordejo/datastorage/update"] = self.reactUpdate
#self.nsmClient.reactions["/agordejo/datastorage/delete"] = self.reactDelete
#NsmClients only returns from init when it has a connection, and on top (for us) when session is ready. It is safe to announce now.
self.nsmClient.broadcast("/agordejo/datastorage/announce", [self.nsmClient.ourClientId, HARD_LIMIT, self.nsmClient.ourOscUrl])
while True:
self.nsmClient.reactToMessage()
sleep(0.05) #20fps update cycle
def getAll(self, msg):
"""A complete data dump, intended to use once after startup.
Will split into multiple reply messages, if needed.
Our mirror datastructure in nsmservercontrol.py calls that on init.
"""
senderHost, senderPort = msg.params
path = "/agordejo/datastorage/reply/getall"
encoded = json.dumps(self.data)
chunks = chunkstring(encoded)
l = len(chunks)
for index, chunk in enumerate(chunks):
listOfParameters = [index+0, l-1, chunk]
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
def getDescription(self, msg)->str:
"""Returns a normal string, not json"""
senderHost, senderPort = msg.params
path = "/agordejo/datastorage/reply/getdescription"
chunks = chunkstring(self.data["description"])
l = len(chunks)
for index, chunk in enumerate(chunks):
listOfParameters = [index+0, l-1, chunk]
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
def setDescription(self, msg):
"""
Answers with descriptionId and index when data was received and saved.
The GUI needs to buffer this a bit. Don't send every char as single message.
This is for multi-part messages
Index is 0 based,
chunk is part of a simple string, not json.
The descriptionId:int indicates the message the chunks belong to.
If we see a new one we reset our storage.
"""
descriptionId, index, chunk, senderHost, senderPort = msg.params #str, int, str, str, int
if not self._descriptionId == descriptionId:
self._descriptionId = descriptionId
self._descriptionStringArray.clear()
self._descriptionStringArray[index] = chunk
buildString = "".join([v for k,v in sorted(self._descriptionStringArray.items())])
self.data["description"] = buildString
self.nsmClient.announceSaveStatus(False)
def getClientOverrideName(self, msg):
"""Answers with empty string if clientId does not exist or has not data. This is a signal
for the GUI/host to use the original name!"""
clientId, senderHost, senderPort = msg.params
path = "/agordejo/datastorage/reply/getclient"
if clientId in self.data["clientOverrideNames"]:
name = self.data["clientOverrideNames"][clientId]
else:
logger.info(f"We were instructed to read client {clientId}, but it does not exist")
name = ""
listOfParameters = [clientId, json.dumps(name)]
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
def setClientOverrideName(self, msg):
"""We accept empty string as a name to remove the name override.
"""
clientId, jsonValue = msg.params
name = json.loads(jsonValue)[:HARD_LIMIT]
if name:
self.data["clientOverrideNames"][clientId] = name
else:
#It is possible that a client not present in our storage will send an empty string. Protect.
if clientId in self.data["clientOverrideNames"]:
del self.data["clientOverrideNames"][clientId]
self.nsmClient.announceSaveStatus(False)
def getTimelineMaximum(self, msg):
"""
In minutes
If the GUI supports global jack transport controls this can be used to remember
the users setting for the maximum timeline duration. JACKs own data is without an upper
bound."""
senderHost, senderPort = msg.params
path = "/agordejo/datastorage/reply/gettimelinemaximum"
if "timelineMaximumDuration" in self.data:
numericValue = self.data["timelineMaximumDuration"]
else:
logger.info(f"We were instructed to read the timeline maximum duration, but it does not exist yet")
numericValue = 5# minutes.
listOfParameters = [json.dumps(numericValue)]
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
def setTimelineMaximum(self, msg):
"""In minutes"""
jsonValue = msg.params[0] #list of 1
numericValue = json.loads(jsonValue)
if numericValue <= 1:
numericValue = 1
self.data["timelineMaximumDuration"] = numericValue
self.nsmClient.announceSaveStatus(False)
#Generic Functions. Not in use and not ready.
#Callback Reactions to OSC. They all receive _IncomingMessage(data)
def reactReadAll(self, msg):
senderHost, senderPort = msg.params
path = "/agordejo/datastorage/reply/readall"
encoded = json.dumps("")
chunks = chunkstring(encoded, 512)
l = len(chunks)
for index, chunk in enumerate(chunks):
listOfParameters = [index+0, l-1, chunk]
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
def reactRead(self, msg):
key, senderHost, senderPort = msg.params
if key in self.data:
path = "/agordejo/datastorage/reply/read"
listOfParameters = [key, json.dumps(self.data[key])]
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
else:
logger.warning(f"We were instructed to read key {key}, but it does not exist")
def reactCreate(self, msg):
key, jsonValue = msg.params
value = json.loads(jsonValue)
self.data[key] = value
self.nsmClient.announceSaveStatus(False)
def reactUpdate(self, msg):
key, jsonValue = msg.params
value = json.loads(jsonValue)
if key in self.data:
self.data[key] = value
self.nsmClient.announceSaveStatus(False)
else:
logger.warning(f"We were instructed to update key {key} with value {value}, but it does not exist")
def reactDelete(self, msg):
key = msg.params[0]
if key in self.data:
del self.data[key]
self.nsmClient.announceSaveStatus(False)
else:
logger.warning(f"We were instructed to delete key {key}, but it does not exist")
#NSM Callbacks and File Handling
def saveCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM):
result = self.data
result["origin"] = URL
result["version"] = VERSION
jsonData = json.dumps(result, indent=2)
try:
with open(self.absoluteJsonFilePath, "w", encoding="utf-8") as f:
f.write(jsonData)
except Exception as e:
logging.error("Will not load or save because: " + e.__repr__())
return self.absoluteJsonFilePath
#nsmclient.py will send save status clean
def openOrNewCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM):
self.absoluteJsonFilePath = pathlib.Path(ourPath)
try:
self.data = self.openFromJson(self.absoluteJsonFilePath)
except FileNotFoundError:
self.data = None #This makes debugging output nicer. If we init Data() here all errors will be presented as follow-up error "while handling exception FileNotFoundError".
except (NotADirectoryError, PermissionError) as e:
self.data = None
logger.error("Will not load or save because: " + e.__repr__())
#Version 1.1 save file updates
if self.data:
if not "timelineMaximumDuration" in self.data:
self.data["timelineMaximumDuration"] = 5 #5 minutes as sensible default
else:
self.data = {"clientOverrideNames":{}, "description":"", "timelineMaximumDuration":5} #5 minutes as sensible default
logger.info("New/Open complete")
#Data is not send here. Instead the gui calls the getAll message later.
def openFromJson(self, absoluteJsonFilePath):
with open(absoluteJsonFilePath, "r", encoding="utf-8") as f:
try:
text = f.read()
result = json.loads(text)
except Exception as error:
result = None
logger.error(error)
if result and "version" in result and "origin" in result and result["origin"] == URL:
if result["version"] <= VERSION:
assert type(result) is dict, (result, type(result))
logger.info("Loading file from json complete")
return result
else:
logger.error(f"""{absoluteJsonFilePath} was saved with {result["version"]} but we need {VERSION}""")
#self.nsmClient.setLabel... We cannot use nsm client here because at this point we are still in the open/new callback. and self.nsmClient does not exist yet.
sysexit()
else:
logger.error(f"""Error. {absoluteJsonFilePath} not loaded. Not a sane agordejo/nsm-data file in json format""")
sysexit()
def sessionIsLoadedCallback(self):
"""At one point I thought we could send our data when session is ready, so the GUI actually
has clients to rename. However, that turned out impossible or impractical.
Instead the GUI now just fails if nameOverrides that we send are not available yet and tries
again later.
Leave that in for documentation.
"""
pass
#def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments):
# print (__file__, "broadcast")
def exitCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM):
sysexit(0)
if __name__ == '__main__':
"""Creating an instance starts the client and does not return"""
try:
DataClient()
except NSMNotRunningError:
parser.print_help()