Browse Source

Add save and load of max timeline through nsm-data. Update nsm-data of course

tags/v0.3.0
Nils 2 months ago
parent
commit
32bbd53efd
8 changed files with 157 additions and 26 deletions
  1. +1
    -0
      CHANGELOG
  2. +18
    -0
      engine/api.py
  3. +51
    -3
      engine/nsmservercontrol.py
  4. +1
    -0
      qtgui/designer/mainwindow.py
  5. +3
    -0
      qtgui/designer/mainwindow.ui
  6. +26
    -3
      qtgui/jacktransport.py
  7. +54
    -18
      tools/nsm-data
  8. +3
    -2
      tools/nsmclient.py

+ 1
- 0
CHANGELOG View File

@@ -2,6 +2,7 @@
Remove "Quick" mode. As it turns out "Full" mode is quick enough. Port convenience features to full mode.
Add button in session chooser for alternative access to context menu options
Add a timeline above running session to show global jack transport position. Also add controls to set the position.
Saving the timeline settings per session is done via nsm-data, which increases version from 1.0 to 1.1
Add normal "Save" to tray icon.
Add file integrity check after copying a session
Add progress updates to copy-session.

+ 18
- 0
engine/api.py View File

@@ -64,6 +64,7 @@ class Callbacks(object):
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.
@@ -86,6 +87,19 @@ class Callbacks(object):
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()
@@ -163,6 +177,7 @@ def startEngine():
singleInstanceActivateWindowHook=callbacks._singleInstanceActivateWindow,
dataClientNamesHook=callbacks._dataClientNamesChanged,
dataClientDescriptionHook=callbacks._dataClientDescriptionChanged,
dataClientTimelineMaximumDurationChangedHook=callbacks._dataClientTimelineMaximumDurationChanged,
parameterNsmOSCUrl=PATHS["url"],
sessionRoot=PATHS["sessionRoot"],
startupSession=PATHS["startupSession"],
@@ -343,6 +358,9 @@ def sessionSaveAs(nsmSessionName:str):
def setDescription(text:str):
nsmServerControl.setDescription(text)

def setTimelineMaximumDuration(minutes:int):
nsmServerControl.setTimelineMaximumDuration(int(minutes))

#Client Handling
def clientAdd(executableName):
nsmServerControl.clientAdd(executableName)

+ 51
- 3
engine/nsmservercontrol.py View File

@@ -304,7 +304,7 @@ class NsmServerControl(object):
http://non.tuxfamily.org/nsm/API.html
"""

def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook,
def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook, dataClientTimelineMaximumDurationChangedHook,
parameterNsmOSCUrl=None, sessionRoot=None, startupSession=None, useCallbacks=True):
"""If useCallbacks is False you will see every message in the log.
This is just a development mode to see all messages, unfiltered.
@@ -416,6 +416,7 @@ class NsmServerControl(object):
self.clientStatusHook = clientStatusHook #all client status is done via this single hook. GUIs need to check if they already know the client or not.
self.dataClientNamesHook = dataClientNamesHook
self.dataClientDescriptionHook = dataClientDescriptionHook
self.dataClientTimelineMaximumDurationChangedHook = dataClientTimelineMaximumDurationChangedHook
self.singleInstanceActivateWindowHook = singleInstanceActivateWindowHook #added to self.processSingleInstance() to listen for a message from another wannabe-instance

self._receiverActive = True
@@ -1252,9 +1253,9 @@ class NsmServerControl(object):
if self.dataStorage and clientId == self.dataStorage.ourClientId: #We only care about the current data-storage, not another instance that was started before it.
self.dataClientNamesHook(None)
self.dataClientDescriptionHook(None)
self.dataClientTimelineMaximumDurationChangedHook(None)
self.dataStorage = None


def _reactStatus_stopped(self, clientId:str):
"""The client has stopped and can be restarted.
The status is not saved. NSM will try to open all clients on session open and end in "ready"
@@ -1262,6 +1263,7 @@ class NsmServerControl(object):
if self.dataStorage and clientId == self.dataStorage.ourClientId: #We only care about the current data-storage, not another instance that was started before it.
self.dataClientNamesHook(None)
self.dataClientDescriptionHook(None)
self.dataClientTimelineMaximumDurationChangedHook(None)
self.dataStorage = None

def _reactStatus_launch(self, clientId:str):
@@ -1400,6 +1402,11 @@ class NsmServerControl(object):
if self.dataStorage:
self.dataStorage.setDescription(text)

#data-storage / nsm-data
def setTimelineMaximumDuration(self, minutes:int):
if self.dataStorage:
self.dataStorage.setTimelineMaximumDuration(minutes)

def _checkDirectoryForSymlinks(self, path)->bool:
for p in path.rglob("*"):
if p.is_symlink():
@@ -1632,9 +1639,12 @@ class DataStorage(object):
self.url = url
self.sock = sock
self.ip, self.port = self.sock.getsockname()
self.data = self.getAll() #blocks. our local copy. = {"clientOverrideNames":{clientId:nameOverride}, "description":"str"}

#Get initial data. Directly send to the api->GUI.
self.data = self.getAll() #blocks. our local copy. = {"clientOverrideNames":{clientId:nameOverride}, "description":"str", "timelineMaximumDuration":"minutes in int"}
self.namesToParentAndCallbacks()
self.descriptionToParentAndCallbacks()
self.timelineMaximumDurationToParentAndCallbacks()

def namesToParentAndCallbacks(self):
self.parent.dataClientNamesHook(self.data["clientOverrideNames"])
@@ -1643,6 +1653,9 @@ class DataStorage(object):
"""Every char!!!"""
self.parent.dataClientDescriptionHook(self.data["description"])

def timelineMaximumDurationToParentAndCallbacks(self):
self.parent.dataClientTimelineMaximumDurationChangedHook(self.data["timelineMaximumDuration"])

def _waitForMultipartMessage(self, pOscpath:str)->str:
"""Returns a json string, as if the message was sent as a single one.
Can consist of only one part as well."""
@@ -1679,6 +1692,41 @@ class DataStorage(object):
jsonString = self._waitForMultipartMessage("/agordejo/datastorage/reply/getall")
return json.loads(jsonString)


def setTimelineMaximumDuration(self, minutes:int):
msg = _OutgoingMessage("/agordejo/datastorage/settimelinemaximum")
msg.add_arg(json.dumps(minutes))
self.sock.sendto(msg.build(), self.url)
self.getTimelineMaximumDuration()

def getTimelineMaximumDuration(self):
msg = _OutgoingMessage("/agordejo/datastorage/gettimelinemaximum")
msg.add_arg(self.ip)
msg.add_arg(self.port)
self.sock.sendto(msg.build(), self.url)

#Wait in blocking mode
self.parent._setPause(True)
while True:
try:
data, addr = self.sock.recvfrom(1024)
except socket.timeout:
break

msg = _IncomingMessage(data)
if msg.oscpath == "/agordejo/datastorage/reply/gettimelinemaximum":
jsonMinutes = msg.params[0] #list of one
answerMinutes = json.loads(jsonMinutes)
break
else:
self.parent._queue.append(msg)
self.parent._setPause(False)
#Got answer
assert type(answerMinutes) is int, (answerMinutes, type(answerMinutes))
self.data["timelineMaximumDuration"] = answerMinutes

self.timelineMaximumDurationToParentAndCallbacks()

def setClientOverrideName(self, clientId:str, value):
"""We accept empty string as a name to remove the name override"""
assert clientId in self.clients, self.clients

+ 1
- 0
qtgui/designer/mainwindow.py View File

@@ -44,6 +44,7 @@ class Ui_MainWindow(object):
self.horizontalLayout_2.addWidget(self.jackTransportTimeline)
self.jackTransportMaxTime = QtWidgets.QSpinBox(self.jackTransportControls)
self.jackTransportMaxTime.setMinimum(1)
self.jackTransportMaxTime.setMaximum(999)
self.jackTransportMaxTime.setProperty("value", 5)
self.jackTransportMaxTime.setObjectName("jackTransportMaxTime")
self.horizontalLayout_2.addWidget(self.jackTransportMaxTime)

+ 3
- 0
qtgui/designer/mainwindow.ui View File

@@ -78,6 +78,9 @@
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>999</number>
</property>
<property name="value">
<number>5</number>
</property>

+ 26
- 3
qtgui/jacktransport.py View File

@@ -48,13 +48,14 @@ class JackTransportControls(object):
api.callbacks.sessionOpenReady.append(self.show)
api.callbacks.sessionClosed.append(self.hide)
api.callbacks.setPlaybackSeconds.append(self._react_playbackSeconds)
self.ui.jackTransportMaxTime.valueChanged.connect(lambda v: setattr(self, "currentMaximumInSeconds", v*60))
api.callbacks.dataClientTimelineMaximumDurationChanged.append(self._callback_MaximumChanged)
self.ui.jackTransportMaxTime.valueChanged.connect(self._gui_MaximumChanged)

self.ui.jackTransportPlayPause.clicked.connect(api.jackClient.playPause)
self.ui.jackTransportRewind.clicked.connect(api.jackClient.rewind)

#Maximum Resolution of 100 is not enough. It is too "steppy"
self.ui.jackTransportTimeline.setMaximum(self.ui.jackTransportTimeline.maximum() * self.resolutionFactor)
self.ui.jackTransportTimeline.setMaximum(100 * self.resolutionFactor)
self.ui.jackTransportTimeline.reset()
self.ui.jackTransportTimeline.mousePressEvent = self._timelineMousePressEvent
self.ui.jackTransportTimeline.mouseMoveEvent = self._timelineMouseMoveEvent
@@ -69,13 +70,35 @@ class JackTransportControls(object):
self.ui.jackTransportControls.show()
self.visible = True

def _gui_MaximumChanged(self, minutes:int):
"""Send to the api.
Our own widget will get changed via callback."""
api.setTimelineMaximumDuration(minutes)

def _callback_MaximumChanged(self, minutes:int):
"""Get a new value from the api. This is either from loading or a roundtrip from our
own widget change.
Can be None if nsm-data left the session. In this case we just ignore and continue."""
if minutes: #0 is not possible. May be None.
self.currentMaximumInSeconds = minutes * 60
self.ui.jackTransportMaxTime.blockSignals(True)
self.ui.jackTransportMaxTime.setValue(minutes)
self.ui.jackTransportMaxTime.blockSignals(False)

def _react_playbackSeconds(self, seconds, isTransportRunning):
if self.visible: #optimisation
realPercent = seconds / self.currentMaximumInSeconds
progressPercent = realPercent * 100 * self.resolutionFactor #100 because that is 0.xy -> xy% )
rogressPercent = int(progressPercent)
prettyTime = str(timedelta(seconds=int(seconds))) #timedelta without int will print microseconds
self.ui.jackTransportTimeline.setValue(progressPercent)
if self.currentMaximumInSeconds < 3600: #less than an hour
prettyTime = prettyTime[2:]

if progressPercent <= 100 * self.resolutionFactor:
self.ui.jackTransportTimeline.setValue(progressPercent)
else:
self.ui.jackTransportTimeline.setValue(100 * self.resolutionFactor)

if isTransportRunning:
self.ui.jackTransportPlayPause.setChecked(True)
self.ui.jackTransportTimeline.setFormat("▶ " + prettyTime) #we don't use the Qt format substitutions. We just set a fixed value.

+ 54
- 18
tools/nsm-data View File

@@ -24,7 +24,7 @@ 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.0
VERSION= 1.1

#In case the user tries to run this standalone.
import argparse
@@ -90,13 +90,15 @@ class DataClient(object):
loggingLevel = "error", #"info" for development or debugging, "error" for production. default is error.
)

#Add custom callbacks. They all receive _IncomingMessage(data)
#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/read"] = self.reactRead
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
@@ -110,8 +112,11 @@ class DataClient(object):
sleep(0.05) #20fps update cycle

def getAll(self, msg):
"""A complete data dumb, intended to use once after startup.
Will split into multiple reply messages, if needed"""
"""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)
@@ -133,10 +138,10 @@ class DataClient(object):

def setDescription(self, msg):
"""
Answers with descriptionId and index when data was received and saved.
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.
@@ -148,8 +153,8 @@ class DataClient(object):
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._descriptionStringArray[index] = chunk
buildString = "".join([v for k,v in sorted(self._descriptionStringArray.items())])
self.data["description"] = buildString
self.nsmClient.announceSaveStatus(False)

@@ -158,7 +163,7 @@ class DataClient(object):
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"]:
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")
@@ -167,7 +172,7 @@ class DataClient(object):
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.
"""We accept empty string as a name to remove the name override.
"""
clientId, jsonValue = msg.params
name = json.loads(jsonValue)[:HARD_LIMIT]
@@ -179,6 +184,32 @@ class DataClient(object):
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):
@@ -249,10 +280,15 @@ class DataClient(object):
self.data = None
logger.error("Will not load or save because: " + e.__repr__())

if not self.data:
self.data = {"clientOverrideNames":{}, "description":""}
#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")
#TODO: send data
#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:
@@ -264,7 +300,7 @@ class DataClient(object):
logger.error(error)

if result and "version" in result and "origin" in result and result["origin"] == URL:
if result["version"] >= VERSION:
if result["version"] <= VERSION:
assert type(result) is dict, (result, type(result))
logger.info("Loading file from json complete")
return result
@@ -285,8 +321,8 @@ class DataClient(object):
Leave that in for documentation.
"""
pass

#def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments):
# print (__file__, "broadcast")

+ 3
- 2
tools/nsmclient.py View File

@@ -4,7 +4,7 @@
PyNSMClient - A New Session Manager Client-Library in one file.

The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager
New Session Manager by Nils Hilbricht et al https://new-session-manager.jackaudio.org
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 )

MIT License
@@ -387,7 +387,8 @@ class NSMClient(object):
else:
#osc.udp://hostname:portnumber/
o = urlparse(nsmOSCUrl)
return o.hostname, o.port
#return o.hostname, o.port #this always make the hostname lowercase. usually it does not matter, but we got crash reports. Alternative:
return o.netloc.split(":")[0], o.port

def getExecutableName(self):
"""Finding the actual executable name can be a bit hard

Loading…
Cancel
Save