diff --git a/CHANGELOG b/CHANGELOG index 635a04a..1be5fad 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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. diff --git a/engine/api.py b/engine/api.py index 6ce35a7..b362dcf 100644 --- a/engine/api.py +++ b/engine/api.py @@ -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) diff --git a/engine/nsmservercontrol.py b/engine/nsmservercontrol.py index 93ccc45..1ccd536 100644 --- a/engine/nsmservercontrol.py +++ b/engine/nsmservercontrol.py @@ -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 diff --git a/qtgui/designer/mainwindow.py b/qtgui/designer/mainwindow.py index a9d4f85..006ef78 100644 --- a/qtgui/designer/mainwindow.py +++ b/qtgui/designer/mainwindow.py @@ -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) diff --git a/qtgui/designer/mainwindow.ui b/qtgui/designer/mainwindow.ui index d3016cc..0a6d9ae 100644 --- a/qtgui/designer/mainwindow.ui +++ b/qtgui/designer/mainwindow.ui @@ -78,6 +78,9 @@ 1 + + 999 + 5 diff --git a/qtgui/jacktransport.py b/qtgui/jacktransport.py index 65bab2d..bcee6e1 100644 --- a/qtgui/jacktransport.py +++ b/qtgui/jacktransport.py @@ -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. diff --git a/tools/nsm-data b/tools/nsm-data index a97ac4e..2a7f327 100755 --- a/tools/nsm-data +++ b/tools/nsm-data @@ -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") diff --git a/tools/nsmclient.py b/tools/nsmclient.py index e922ed9..fac7cad 100644 --- a/tools/nsmclient.py +++ b/tools/nsmclient.py @@ -4,7 +4,7 @@ PyNSMClient - A New Session Manager Client-Library in one file. The Non-Session-Manager by Jonathan Moore Liles : 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