]> git.0d.be Git - jack_mixer.git/commitdiff
Add nsm support. Remove lash
authorDaniel Sheeler <dsheeler@pobox.com>
Thu, 25 Jun 2020 09:33:25 +0000 (04:33 -0500)
committerDaniel Sheeler <dsheeler@pobox.com>
Thu, 25 Jun 2020 09:33:25 +0000 (04:33 -0500)
INSTALL
Makefile.am
jack_mixer.py
nsmclient.py [new file with mode: 0644]

diff --git a/INSTALL b/INSTALL
index ba69b665071fe28eef753e9c113abf996f07634b..07b2b27d8f2fae19a972ca93beb4105ff768446b 100644 (file)
--- a/INSTALL
+++ b/INSTALL
@@ -17,8 +17,6 @@ Runtime requirements (checked by configure script):
  * Pygobject
 
 Runtime optional things, that will enable you to get full functionality:
- * To get LASH (Linux audio session) support, you need LASH compiled
-   with Python bindings. This means at least 0.5.3 release.
  * PyXDG to save preferences between invocations.
 
 ===================================================
index dba5d9846c866ef891960b45497edc09a5acb237..d427880fa9f90f3c35d220d3ddd3b42fd2d305ec 100644 (file)
@@ -38,7 +38,7 @@ jack_mixer_c_la_SOURCES = \
        jack_mixer.c jack_mixer.h list.h memory_atomic.c memory_atomic.h log.h log.c scale.c scale.h jack_compat.h \
        jack_mixer_c.c
 
-dist_jack_mixer_DATA = abspeak.py channel.py gui.py meter.py scale.py serialization.py serialization_xml.py slider.py preferences.py
+dist_jack_mixer_DATA = abspeak.py channel.py gui.py meter.py scale.py serialization.py serialization_xml.py slider.py preferences.py nsmclient.py
 
 CLEANFILES = *.pyc
 EXTRA_DIST = test.py COPYING jack_mixer.py NEWS
index 2597a957e3dfc1d7c1d2f0bdad17c19da1daf9a5..01fa39527ac6f18449a526e6a0f6004358652230 100755 (executable)
@@ -30,12 +30,6 @@ import sys
 import os
 import signal
 
-try:
-    import lash
-except:
-    lash = None
-    print("Cannot load LASH python bindings, you want them unless you enjoy manual jack plumbing each time you use this app", file=sys.stderr)
-
 # temporary change Python modules lookup path to look into installation
 # directory ($prefix/share/jack_mixer/)
 old_path = sys.path
@@ -44,7 +38,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..', 'share', 'ja
 
 import jack_mixer_c
 
-
 import scale
 from channel import *
 
@@ -54,6 +47,8 @@ from preferences import PreferencesDialog
 from serialization_xml import XmlSerialization
 from serialization import SerializedObject, Serializator
 
+from nsmclient import NSMClient
+
 # restore Python modules lookup path
 sys.path = old_path
 
@@ -70,28 +65,39 @@ class JackMixer(SerializedObject):
 
     _init_solo_channels = None
 
-    def __init__(self, name, lash_client):
-        self.mixer = jack_mixer_c.Mixer(name)
+    def __init__(self):
+
+        if os.environ.get('NSM_URL'):
+            self.nsm_client = NSMClient(prettyName = "jack_mixer",
+                                        saveCallback = self.nsm_save_cb,
+                                        openOrNewCallback = self.nsm_open_cb,
+                                        supportsSaveStatus = False,
+                                        exitProgramCallback = self.nsm_exit_cb,
+                                        loggingLevel = "error",
+                                       )
+        else:
+            self.create_mixer('jack_mixer', with_nsm = False)
+
+
+    def create_mixer(self, client_name, with_nsm = True):
+        self.create_ui(with_nsm)
+        self.mixer = jack_mixer_c.Mixer(client_name)
         if not self.mixer:
-            return
-        self.monitor_channel = self.mixer.add_output_channel("Monitor", True, True)
+            sys.exit(1)
+        self.window.set_title(client_name)
 
+        self.monitor_channel = self.mixer.add_output_channel("Monitor", True, True)
         self.save = False
 
-        if lash_client:
-            # Send our client name to server
-            lash_event = lash.lash_event_new_with_type(lash.LASH_Client_Name)
-            lash.lash_event_set_string(lash_event, name)
-            lash.lash_send_event(lash_client, lash_event)
-
-            lash.lash_jack_client_name(lash_client, name)
+        GLib.timeout_add(80, self.read_meters)
+        if with_nsm:
+            GLib.timeout_add(200, self.nsm_react)
+        GLib.timeout_add(50, self.midi_events_check)
 
+    def create_ui(self, with_nsm):
+        self.channels = []
+        self.output_channels = []
         self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
-        if name != self.mixer.client_name():
-            self.window.set_title(name + " ("+ self.mixer.client_name()+")" )
-        else:
-            self.window.set_title(name)
-
         self.window.set_icon_name('jack_mixer')
         self.gui_factory = gui.Factory(self.window, self.meter_scales, self.slider_scales)
 
@@ -110,32 +116,34 @@ class JackMixer(SerializedObject):
 
         self.window.set_default_size(120, 300)
 
-        mixer_menu = Gtk.Menu()
-        mixer_menu_item.set_submenu(mixer_menu)
+        self.mixer_menu = Gtk.Menu()
+        mixer_menu_item.set_submenu(self.mixer_menu)
 
         add_input_channel = Gtk.MenuItem.new_with_mnemonic('New _Input Channel')
-        mixer_menu.append(add_input_channel)
+        self.mixer_menu.append(add_input_channel)
         add_input_channel.connect("activate", self.on_add_input_channel)
 
         add_output_channel = Gtk.MenuItem.new_with_mnemonic('New _Output Channel')
-        mixer_menu.append(add_output_channel)
+        self.mixer_menu.append(add_output_channel)
         add_output_channel.connect("activate", self.on_add_output_channel)
 
-        mixer_menu.append(Gtk.SeparatorMenuItem())
-        open = Gtk.MenuItem.new_with_mnemonic('_Open')
-        mixer_menu.append(open)
-        open.connect('activate', self.on_open_cb)
+        self.mixer_menu.append(Gtk.SeparatorMenuItem())
+        if not with_nsm:
+            open = Gtk.MenuItem.new_with_mnemonic('_Open')
+            self.mixer_menu.append(open)
+            open.connect('activate', self.on_open_cb)
         save = Gtk.MenuItem.new_with_mnemonic('_Save')
-        mixer_menu.append(save)
+        self.mixer_menu.append(save)
         save.connect('activate', self.on_save_cb)
-        save_as = Gtk.MenuItem.new_with_mnemonic('Save_As')
-        mixer_menu.append(save_as)
-        save_as.connect('activate', self.on_save_as_cb)
+        if not with_nsm:
+            save_as = Gtk.MenuItem.new_with_mnemonic('Save_As')
+            self.mixer_menu.append(save_as)
+            save_as.connect('activate', self.on_save_as_cb)
 
-        mixer_menu.append(Gtk.SeparatorMenuItem())
+        self.mixer_menu.append(Gtk.SeparatorMenuItem())
 
         quit = Gtk.MenuItem.new_with_mnemonic('_Quit')
-        mixer_menu.append(quit)
+        self.mixer_menu.append(quit)
         quit.connect('activate', self.on_quit_cb)
 
         edit_menu = Gtk.Menu()
@@ -189,8 +197,6 @@ class JackMixer(SerializedObject):
         self.hbox_inputs.set_border_width(0)
         self.hbox_top.set_spacing(0)
         self.hbox_top.set_border_width(0)
-        self.channels = []
-        self.output_channels = []
 
         self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
         self.scrolled_window.add(self.hbox_inputs)
@@ -206,12 +212,29 @@ class JackMixer(SerializedObject):
 
         self.window.connect('delete-event', self.on_delete_event)
 
-        GLib.timeout_add(80, self.read_meters)
-        self.lash_client = lash_client
+    def nsm_react(self):
+        self.nsm_client.reactToMessage()
+        return True
 
-        GLib.timeout_add(200, self.lash_check_events)
+    def nsm_open_cb(self, path, session_name, client_name):
+        self.create_mixer(client_name, with_nsm = True)
 
-        GLib.timeout_add(50, self.midi_events_check)
+        if os.path.isfile(path):
+            f = open(path, 'r')
+            self.load_from_xml(f)
+            f.close()
+        else:
+            f = open(path, 'w')
+            f.close()
+        self.current_filename = path
+
+    def nsm_save_cb(self, path, session_name, client_name):
+        f = open(path, 'w')
+        self.save_to_xml(f)
+        f.close()
+
+    def nsm_exit_cb(self, path, session_name, client_name):
+        Gtk.main_quit()
 
     def on_delete_event(self, widget, event):
         return False
@@ -577,54 +600,6 @@ Franklin Street, Fifth Floor, Boston, MA 02110-130159 USA''')
         about.run()
         about.destroy()
 
-    def lash_check_events(self):
-        if self.save:
-            self.save = False
-            if self.current_filename:
-                print("saving on SIGUSR1 request")
-                self.on_save_cb()
-                print("save done")
-            else:
-                print("not saving because filename is not known")
-            return True
-
-        if not self.lash_client:
-            return True
-
-        while lash.lash_get_pending_event_count(self.lash_client):
-            event = lash.lash_get_event(self.lash_client)
-
-            #print repr(event)
-
-            event_type = lash.lash_event_get_type(event)
-            if event_type == lash.LASH_Quit:
-                print("jack_mixer: LASH ordered quit.")
-                Gtk.main_quit()
-                return False
-            elif event_type == lash.LASH_Save_File:
-                directory = lash.lash_event_get_string(event)
-                print("jack_mixer: LASH ordered to save data in directory %s" % directory)
-                filename = directory + os.sep + "jack_mixer.xml"
-                f = open(filename, "w")
-                self.save_to_xml(f)
-                f.close()
-                lash.lash_send_event(self.lash_client, event) # we crash with double free
-            elif event_type == lash.LASH_Restore_File:
-                directory = lash.lash_event_get_string(event)
-                print("jack_mixer: LASH ordered to restore data from directory %s" % directory)
-                filename = directory + os.sep + "jack_mixer.xml"
-                f = open(filename, "r")
-                self.load_from_xml(f, silence_errors=True)
-                f.close()
-                lash.lash_send_event(self.lash_client, event)
-            else:
-                print("jack_mixer: Got unhandled LASH event, type " + str(event_type))
-                return True
-
-            #lash.lash_event_destroy(event)
-
-        return True
-
     def save_to_xml(self, file):
         #print "Saving to XML..."
         b = XmlSerialization()
@@ -717,55 +692,32 @@ def help():
     print("Usage: %s [mixer_name]" % sys.argv[0])
 
 def main():
-    # Connect to LASH if Python bindings are available, and the user did not
-    # pass --no-lash
-    if lash and not '--no-lash' in sys.argv:
-        # sys.argv is modified by this call
-        lash_client = lash.init(sys.argv, "jack_mixer", lash.LASH_Config_File)
-    else:
-        lash_client = None
-
     parser = OptionParser(usage='usage: %prog [options] [jack_client_name]')
     parser.add_option('-c', '--config', dest='config',
                       help='use a non default configuration file')
-    # --no-lash here is not acted upon, it is specified for completeness when
     # --help is passed.
-    parser.add_option('--no-lash', dest='nolash', action='store_true',
-                      help='do not connect to LASH')
     options, args = parser.parse_args()
 
-    # Yeah , this sounds stupid, we connected earlier, but we dont want to show this if we got --help option
-    # This issue should be fixed in pylash, there is a reason for having two functions for initialization after all
-    if lash_client:
-        server_name = lash.lash_get_server_name(lash_client)
-        if server_name:
-            print("Successfully connected to LASH server at " + server_name)
-        else:
-            # getting the server name failed, probably not worth trying to do
-            # further things with as a lash client.
-            lash_client = None
+    mixer = None
 
     if len(args) == 1:
         name = args[0]
     else:
         name = None
 
-    if not name:
-        name = "jack_mixer"
-
-    try:
-        mixer = JackMixer(name, lash_client)
-    except Exception as e:
-        err = Gtk.MessageDialog(None,
-                            Gtk.DialogFlags.MODAL,
-                            Gtk.MessageType.ERROR,
-                            Gtk.ButtonsType.OK,
-                            "Mixer creation failed (%s)" % str(e))
-        err.run()
-        err.destroy()
-        sys.exit(1)
+        try:
+            mixer = JackMixer()
+        except Exception as e:
+            err = Gtk.MessageDialog(None,
+                                    Gtk.DialogFlags.MODAL,
+                                    Gtk.MessageType.ERROR,
+                                    Gtk.ButtonsType.OK,
+                                    "Mixer creation failed (%s)" % str(e))
+            err.run()
+            err.destroy()
+            sys.exit(1)
 
-    if options.config:
+    if not hasattr(mixer, 'nsm_client') and options.config:
         f = open(options.config)
         mixer.current_filename = options.config
         try:
diff --git a/nsmclient.py b/nsmclient.py
new file mode 100644 (file)
index 0000000..42c1e56
--- /dev/null
@@ -0,0 +1,717 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+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
+With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 )
+
+MIT License
+
+Copyright 2014-2020 Nils Hilbricht https://www.laborejo.org
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction,
+including without limitation the rights to use, copy, modify, merge, publish, distribute,
+sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or
+substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
+OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+"""
+
+import logging;
+logger = None #filled by init with prettyName
+
+import struct
+import socket
+from os import getenv, getpid, kill
+import os
+import os.path
+import shutil
+from uuid import uuid4
+from sys import argv
+from signal import signal, SIGTERM, SIGINT, SIGKILL #react to exit signals to close the client gracefully. Or kill if the client fails to do so.
+from urllib.parse import urlparse
+
+class _IncomingMessage(object):
+    """Representation of a parsed datagram representing an OSC message.
+
+    An OSC message consists of an OSC Address Pattern followed by an OSC
+    Type Tag String followed by zero or more OSC Arguments.
+    """
+
+    def __init__(self, dgram):
+        #NSM Broadcasts are bundles, but very simple ones. We only need to care about the single message it contains.
+        #Therefore we can strip the bundle prefix and handle it as normal message.
+        if b"#bundle" in dgram:
+            bundlePrefix, singleMessage = dgram.split(b"/", maxsplit=1)
+            dgram = b"/" + singleMessage  # / eaten by split
+            self.isBroadcast = True
+        else:
+            self.isBroadcast = False
+        self.LENGTH = 4 #32 bit
+        self._dgram = dgram
+        self._parameters = []
+        self.parse_datagram()
+
+
+    def get_int(self, dgram, start_index):
+        """Get a 32-bit big-endian two's complement integer from the datagram.
+
+        Args:
+        dgram: A datagram packet.
+        start_index: An index where the integer starts in the datagram.
+
+        Returns:
+        A tuple containing the integer and the new end index.
+
+        Raises:
+        ValueError if the datagram could not be parsed.
+        """
+        try:
+            if len(dgram[start_index:]) < self.LENGTH:
+                raise ValueError('Datagram is too short')
+            return (
+                struct.unpack('>i', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH)
+        except (struct.error, TypeError) as e:
+            raise ValueError('Could not parse datagram %s' % e)
+
+    def get_string(self, dgram, start_index):
+        """Get a python string from the datagram, starting at pos start_index.
+
+        We receive always the full string, but handle only the part from the start_index internally.
+        In the end return the offset so it can be added to the index for the next parameter.
+        Each subsequent call handles less of the same string, starting further to the right.
+
+        According to the specifications, a string is:
+        "A sequence of non-null ASCII characters followed by a null,
+        followed by 0-3 additional null characters to make the total number
+        of bits a multiple of 32".
+
+        Args:
+        dgram: A datagram packet.
+        start_index: An index where the string starts in the datagram.
+
+        Returns:
+        A tuple containing the string and the new end index.
+
+        Raises:
+        ValueError if the datagram could not be parsed.
+        """
+        #First test for empty string, which is nothing, followed by a terminating \x00 padded by three additional \x00.
+        if dgram[start_index:].startswith(b"\x00\x00\x00\x00"):
+            return "", start_index + 4
+
+        #Otherwise we have a non-empty string that must follow the rules of the docstring.
+
+        offset = 0
+        try:
+            while dgram[start_index + offset] != 0:
+                offset += 1
+            if offset == 0:
+                raise ValueError('OSC string cannot begin with a null byte: %s' % dgram[start_index:])
+            # Align to a byte word.
+            if (offset) % self.LENGTH == 0:
+                offset += self.LENGTH
+            else:
+                offset += (-offset % self.LENGTH)
+            # Python slices do not raise an IndexError past the last index,
+                # do it ourselves.
+            if offset > len(dgram[start_index:]):
+                raise ValueError('Datagram is too short')
+            data_str = dgram[start_index:start_index + offset]
+            return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset
+        except IndexError as ie:
+            raise ValueError('Could not parse datagram %s' % ie)
+        except TypeError as te:
+            raise ValueError('Could not parse datagram %s' % te)
+
+    def get_float(self, dgram, start_index):
+        """Get a 32-bit big-endian IEEE 754 floating point number from the datagram.
+
+          Args:
+            dgram: A datagram packet.
+            start_index: An index where the float starts in the datagram.
+
+          Returns:
+            A tuple containing the float and the new end index.
+
+          Raises:
+            ValueError if the datagram could not be parsed.
+        """
+        try:
+            return (struct.unpack('>f', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH)
+        except (struct.error, TypeError) as e:
+            raise ValueError('Could not parse datagram %s' % e)
+
+    def parse_datagram(self):
+        try:
+            self._address_regexp, index = self.get_string(self._dgram, 0)
+            if not self._dgram[index:]:
+                # No params is legit, just return now.
+                return
+
+            # Get the parameters types.
+            type_tag, index = self.get_string(self._dgram, index)
+            if type_tag.startswith(','):
+                type_tag = type_tag[1:]
+
+            # Parse each parameter given its type.
+            for param in type_tag:
+                if param == "i":  # Integer.
+                    val, index = self.get_int(self._dgram, index)
+                elif param == "f":  # Float.
+                    val, index = self.get_float(self._dgram, index)
+                elif param == "s":  # String.
+                    val, index = self.get_string(self._dgram, index)
+                else:
+                    logger.warning("Unhandled parameter type: {0}".format(param))
+                    continue
+                self._parameters.append(val)
+        except ValueError as pe:
+            #raise ValueError('Found incorrect datagram, ignoring it', pe)
+            # Raising an error is not ignoring it!
+            logger.warning("Found incorrect datagram, ignoring it. {}".format(pe))
+
+    @property
+    def oscpath(self):
+        """Returns the OSC address regular expression."""
+        return self._address_regexp
+
+    @staticmethod
+    def dgram_is_message(dgram):
+        """Returns whether this datagram starts as an OSC message."""
+        return dgram.startswith(b'/')
+
+    @property
+    def size(self):
+        """Returns the length of the datagram for this message."""
+        return len(self._dgram)
+
+    @property
+    def dgram(self):
+        """Returns the datagram from which this message was built."""
+        return self._dgram
+
+    @property
+    def params(self):
+        """Convenience method for list(self) to get the list of parameters."""
+        return list(self)
+
+    def __iter__(self):
+        """Returns an iterator over the parameters of this message."""
+        return iter(self._parameters)
+
+class _OutgoingMessage(object):
+    def __init__(self, oscpath):
+        self.LENGTH = 4 #32 bit
+        self.oscpath = oscpath
+        self._args = []
+
+    def write_string(self, val):
+        dgram = val.encode('utf-8')
+        diff = self.LENGTH - (len(dgram) % self.LENGTH)
+        dgram += (b'\x00' * diff)
+        return dgram
+
+    def write_int(self, val):
+        return struct.pack('>i', val)
+
+    def write_float(self, val):
+        return struct.pack('>f', val)
+
+    def add_arg(self, argument):
+        t = {str:"s", int:"i", float:"f"}[type(argument)]
+        self._args.append((t, argument))
+
+    def build(self):
+        dgram = b''
+
+        #OSC Path
+        dgram += self.write_string(self.oscpath)
+
+        if not self._args:
+            dgram += self.write_string(',')
+            return dgram
+
+        # Write the parameters.
+        arg_types = "".join([arg[0] for arg in self._args])
+        dgram += self.write_string(',' + arg_types)
+        for arg_type, value in self._args:
+            f = {"s":self.write_string, "i":self.write_int, "f":self.write_float}[arg_type]
+            dgram += f(value)
+        return dgram
+
+class NSMNotRunningError(Exception):
+    """Error raised when environment variable $NSM_URL was not found."""
+
+class NSMClient(object):
+    """The representation of the host programs as NSM sees it.
+    Technically consists of an udp server and a udp client.
+
+    Does not run an event loop itself and depends on the host loop.
+    E.g. a Qt timer or just a simple while True: sleep(0.1) in Python."""
+    def __init__(self, prettyName, supportsSaveStatus, saveCallback, openOrNewCallback, exitProgramCallback, hideGUICallback=None, showGUICallback=None, broadcastCallback=None, sessionIsLoadedCallback=None, loggingLevel = "info"):
+
+        self.nsmOSCUrl = self.getNsmOSCUrl() #this fails and raises NSMNotRunningError if NSM is not available. Host programs can ignore it or exit their program.
+
+        self.realClient = True
+        self.cachedSaveStatus = None #save status checks for this.
+
+        global logger
+        logger = logging.getLogger(prettyName)
+        logger.info("import")
+        if loggingLevel == "info" or loggingLevel == 20:
+            logging.basicConfig(level=logging.INFO) #development
+            logger.info("Starting PyNSM2 Client with logging level INFO. Switch to 'error' for a release!") #the NSM name is not ready yet so we just use the pretty name
+        elif loggingLevel == "error" or loggingLevel == 40:
+            logging.basicConfig(level=logging.ERROR) #production
+        else:
+            logging.warning("Unknown logging level: {}. Choose 'info' or 'error'".format(loggingLevel))
+            logging.basicConfig(level=logging.INFO) #development
+
+        #given parameters,
+        self.prettyName = prettyName #keep this consistent! Settle for one name.
+        self.supportsSaveStatus = supportsSaveStatus
+        self.saveCallback = saveCallback
+        self.exitProgramCallback = exitProgramCallback
+        self.openOrNewCallback = openOrNewCallback #The host needs to: Create a jack client with ourClientNameUnderNSM - Open the saved file and all its resources
+        self.broadcastCallback = broadcastCallback
+        self.hideGUICallback = hideGUICallback
+        self.showGUICallback = showGUICallback
+        self.sessionIsLoadedCallback = sessionIsLoadedCallback
+
+        #Reactions get the raw _IncomingMessage OSC object
+        #A client can add to reactions.
+        self.reactions = {
+                          "/nsm/client/save" : self._saveCallback,
+                          "/nsm/client/show_optional_gui" : lambda msg: self.showGUICallback(),
+                          "/nsm/client/hide_optional_gui" : lambda msg: self.hideGUICallback(),
+                          "/nsm/client/session_is_loaded" : self._sessionIsLoadedCallback,
+                          #Hello source-code reader. You can add your own reactions here by nsmClient.reactions[oscpath]=func, where func gets the raw _IncomingMessage OSC object as argument.
+                          #broadcast is handled directly by the function because it has more parameters
+                          }
+        #self.discardReactions = set(["/nsm/client/session_is_loaded"])
+        self.discardReactions = set()
+
+        #Networking and Init
+        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp
+        self.sock.bind(('', 0)) #pick a free port on localhost.
+        ip, port = self.sock.getsockname()
+        self.ourOscUrl = f"osc.udp://{ip}:{port}/"
+
+        self.executableName = self.getExecutableName()
+
+        #UNIX Signals. Used for quit.
+        signal(SIGTERM, self.sigtermHandler) #NSM sends only SIGTERM. #TODO: really? pynsm version 1 handled sigkill as well.
+        signal(SIGINT, self.sigtermHandler)
+
+        #The following instance parameters are all set in announceOurselves
+        self.serverFeatures = None
+        self.sessionName = None
+        self.ourPath = None
+        self.ourClientNameUnderNSM = None
+        self.ourClientId = None # the "file extension" of ourClientNameUnderNSM
+        self.isVisible = None #set in announceGuiVisibility
+        self.saveStatus = True # true is clean. false means we need saving.
+
+        self.announceOurselves()
+
+        assert self.serverFeatures, self.serverFeatures
+        assert self.sessionName, self.sessionName
+        assert self.ourPath, self.ourPath
+        assert self.ourClientNameUnderNSM, self.ourClientNameUnderNSM
+
+        self.sock.setblocking(False) #We have waited for tha handshake. Now switch blocking off because we expect sock.recvfrom to be empty in 99.99...% of the time so we shouldn't wait for the answer.
+        #After this point the host must include self.reactToMessage in its event loop
+
+        #We assume we are save at startup.
+        self.announceSaveStatus(isClean = True)
+
+        logger.info("NSMClient client init complete. Going into listening mode.")
+
+
+    def reactToMessage(self):
+        """This is the main loop message. It is added to the clients event loop."""
+        try:
+            data, addr = self.sock.recvfrom(4096) #4096 is quite big. We don't expect nsm messages this big. Better safe than sorry. However, messages will crash the program if they are bigger than 4096.
+        except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not.
+            return None
+
+        msg = _IncomingMessage(data)
+        if msg.oscpath in self.reactions:
+            self.reactions[msg.oscpath](msg)
+        elif msg.oscpath in self.discardReactions:
+            pass
+        elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/open", "Loaded."]: #NSM sends that all programs of the session were loaded.
+            logger.info ("Got /reply Loaded from NSM Server")
+        elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/save", "Saved."]: #NSM sends that all program-states are saved. Does only happen from the general save instruction, not when saving our client individually
+            logger.info ("Got /reply Saved from NSM Server")
+        elif msg.isBroadcast:
+            if self.broadcastCallback:
+                logger.info (f"Got broadcast with messagePath {msg.oscpath} and listOfArguments {msg.params}")
+                self.broadcastCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM, msg.oscpath, msg.params)
+            else:
+                logger.info (f"No callback for broadcast! Got messagePath {msg.oscpath} and listOfArguments {msg.params}")
+        elif msg.oscpath == "/error":
+            logger.warning("Got /error from NSM Server. Path: {} , Parameter: {}".format(msg.oscpath, msg.params))
+        else:
+            logger.warning("Reaction not implemented:. Path: {} , Parameter: {}".format(msg.oscpath, msg.params))
+
+
+    def send(self, path:str, listOfParameters:list, host=None, port=None):
+        """Send any osc message. Defaults to nsmd URL.
+        Will not wait for an answer but return None."""
+        if host and port:
+            url = (host, port)
+        else:
+            url = self.nsmOSCUrl
+        msg = _OutgoingMessage(path)
+        for arg in listOfParameters:
+            msg.add_arg(arg) #type is auto-determined by outgoing message
+        self.sock.sendto(msg.build(), url)
+
+    def getNsmOSCUrl(self):
+        """Return and save the nsm osc url or raise an error"""
+        nsmOSCUrl = getenv("NSM_URL")
+        if not nsmOSCUrl:
+            raise NSMNotRunningError("New-Session-Manager environment variable $NSM_URL not found.")
+        else:
+            #osc.udp://hostname:portnumber/
+            o = urlparse(nsmOSCUrl)
+            return o.hostname, o.port
+
+    def getExecutableName(self):
+        """Finding the actual executable name can be a bit hard
+        in Python. NSM wants the real starting point, even if
+        it was a bash script.
+        """
+        #TODO: I really don't know how to find out the name of the bash script
+        fullPath = argv[0]
+        assert os.path.dirname(fullPath) in os.environ["PATH"], (fullPath, os.path.dirname(fullPath), os.environ["PATH"]) #NSM requires the executable to be in the path. No excuses. This will never happen since the reference NSM server-GUI already checks for this.
+
+        executableName = os.path.basename(fullPath)
+        assert not "/" in executableName, executableName #see above.
+        return executableName
+
+    def announceOurselves(self):
+        """Say hello to NSM and tell it we are ready to receive
+        instructions
+
+        /nsm/server/announce s:application_name s:capabilities s:executable_name i:api_version_major i:api_version_minor i:pid"""
+
+        def buildClientFeaturesString():
+            #:dirty:switch:progress:
+            result = []
+            if self.supportsSaveStatus:
+                result.append("dirty")
+            if self.hideGUICallback and self.showGUICallback:
+                result.append("optional-gui")
+            if result:
+                return ":".join([""] + result + [""])
+            else:
+                return ""
+
+        logger.info("Sending our NSM-announce message")
+
+        announce = _OutgoingMessage("/nsm/server/announce")
+        announce.add_arg(self.prettyName)  #s:application_name
+        announce.add_arg(buildClientFeaturesString()) #s:capabilities
+        announce.add_arg(self.executableName)  #s:executable_name
+        announce.add_arg(1)  #i:api_version_major
+        announce.add_arg(2)  #i:api_version_minor
+        announce.add_arg(int(getpid())) #i:pid
+        hostname, port = self.nsmOSCUrl
+        assert hostname, self.nsmOSCUrl
+        assert port, self.nsmOSCUrl
+        self.sock.sendto(announce.build(), self.nsmOSCUrl)
+
+        #Wait for /reply (aka 'Howdy, what took you so long?)
+        data, addr = self.sock.recvfrom(1024)
+        msg = _IncomingMessage(data)
+
+        if msg.oscpath == "/error":
+            originalMessage, errorCode, reason = msg.params
+            logger.error("Code {}: {}".format(errorCode, reason))
+            quit()
+
+        elif msg.oscpath == "/reply":
+            nsmAnnouncePath, welcomeMessage, managerName, self.serverFeatures = msg.params
+            assert nsmAnnouncePath == "/nsm/server/announce", nsmAnnouncePath
+            logger.info("Got /reply " + welcomeMessage)
+
+            #Wait for /nsm/client/open
+            data, addr = self.sock.recvfrom(1024)
+            msg = _IncomingMessage(data)
+            assert msg.oscpath == "/nsm/client/open", msg.oscpath
+            self.ourPath, self.sessionName, self.ourClientNameUnderNSM = msg.params
+            self.ourClientId = os.path.splitext(self.ourClientNameUnderNSM)[1][1:]
+            logger.info("Got '/nsm/client/open' from NSM. Telling our client to load or create a file with name {}".format(self.ourPath))
+            self.openOrNewCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM) #Host function to either load an existing session or create a new one.
+            logger.info("Our client should be done loading or creating the file {}".format(self.ourPath))
+            replyToOpen = _OutgoingMessage("/reply")
+            replyToOpen.add_arg("/nsm/client/open")
+            replyToOpen.add_arg("{} is opened or created".format(self.prettyName))
+            self.sock.sendto(replyToOpen.build(), self.nsmOSCUrl)
+        else:
+            raise ValueError("Unexpected message path after announce: {}".format((msg.oscpath, msg.params)))
+
+    def announceGuiVisibility(self, isVisible):
+        message = "/nsm/client/gui_is_shown" if isVisible else "/nsm/client/gui_is_hidden"
+        self.isVisible = isVisible
+        guiVisibility = _OutgoingMessage(message)
+        logger.info("Telling NSM that our clients switched GUI visibility to: {}".format(message))
+        self.sock.sendto(guiVisibility.build(), self.nsmOSCUrl)
+
+    def announceSaveStatus(self, isClean):
+        """Only send to the NSM Server if there was really a change"""
+        if not self.supportsSaveStatus:
+            return
+
+        if not isClean == self.cachedSaveStatus:
+            message = "/nsm/client/is_clean" if isClean else "/nsm/client/is_dirty"
+            self.cachedSaveStatus = isClean
+            saveStatus = _OutgoingMessage(message)
+            logger.info("Telling NSM that our clients save state is now: {}".format(message))
+            self.sock.sendto(saveStatus.build(), self.nsmOSCUrl)
+
+    def _saveCallback(self, msg):
+        logger.info("Telling our client to save as {}".format(self.ourPath))
+        self.saveCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM)
+        replyToSave = _OutgoingMessage("/reply")
+        replyToSave.add_arg("/nsm/client/save")
+        replyToSave.add_arg("{} saved".format(self.prettyName))
+        self.sock.sendto(replyToSave.build(), self.nsmOSCUrl)
+        #it is assumed that after saving the state is clear
+        self.announceSaveStatus(isClean = True)
+
+
+    def _sessionIsLoadedCallback(self, msg):
+        if self.sessionIsLoadedCallback:
+            logger.info("Received 'Session is Loaded'. Our client supports it. Forwarding message...")
+            self.sessionIsLoadedCallback()
+        else:
+            logger.info("Received 'Session is Loaded'. Our client does not support it, which is the default. Discarding message...")
+
+    def sigtermHandler(self, signal, frame):
+        """Wait for the user to quit the program
+
+        The user function does not need to exit itself.
+        Just shutdown audio engines etc.
+
+        It is possible, that the client does not implement quit
+        properly. In that case NSM protocol demands that we quit anyway.
+        No excuses.
+
+        Achtung GDB! If you run your program with
+            gdb --args python foo.py
+        the Python signal handler will not work. This has nothing to do with this library.
+        """
+        logger.info("Telling our client to quit.")
+        self.exitProgramCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM)
+        #There is a chance that exitProgramCallback will hang and the program won't quit. However, this is broken design and bad programming. We COULD place a timeout here and just kill after 10s or so, but that would make quitting our responsibility and fixing a broken thing.
+        #If we reach this point we have reached the point of no return. Say goodbye.
+        logger.warning("Client did not quit on its own. Sending SIGKILL.")
+        kill(getpid(), SIGKILL)
+        logger.error("SIGKILL did nothing. Do it manually.")
+
+    def debugResetDataAndExit(self):
+        """This is solely meant for debugging and testing. The user way of action should be to
+        remove the client from the session and add a new instance, which will get a different
+        NSM-ID.
+        Afterwards we perform a clean exit."""
+        logger.warning("debugResetDataAndExit will now delete {} and then request an exit.".format(self.ourPath))
+        if os.path.exists(self.ourPath):
+            if os.path.isfile(self.ourPath):
+                try:
+                    os.remove(self.ourPath)
+                except Exception as e:
+                    logger.info(e)
+            elif os.path.isdir(self.ourPath):
+                try:
+                    shutil.rmtree(self.ourPath)
+                except Exception as e:
+                    logger.info(e)
+        else:
+            logger.info("{} does not exist.".format(self.ourPath))
+        self.serverSendExitToSelf()
+
+    def serverSendExitToSelf(self):
+        """If you want a very strict client you can block any non-NSM quit-attempts, like ignoring a
+        qt closeEvent, and instead send the NSM Server a request to close this client.
+        This method is a shortcut to do just that.
+
+        Using this method will not result in a NSM-"client died unexpectedly"  message that usually
+        happens a client quits on its own. This message is harmless but may confuse a user."""
+
+        logger.info("instructing the NSM-Server to send SIGTERM to ourselves.")
+        if "server-control" in self.serverFeatures:
+            message = _OutgoingMessage("/nsm/server/stop")
+            message.add_arg("{}".format(self.ourClientId))
+            self.sock.sendto(message.build(), self.nsmOSCUrl)
+        else:
+            logger.warning("...but the NSM-Server does not support server control. Quitting on our own. Server only supports: {}".format(self.serverFeatures))
+            kill(getpid(), SIGTERM) #this calls the exit callback but nsm will output something like "client died unexpectedly."
+
+    def serverSendSaveToSelf(self):
+        """Some clients want to offer a manual Save function, mostly for psychological reasons.
+        We offer a clean solution in calling this function which will trigger a round trip over the
+        NSM server so our client thinks it received a Save instruction. This leads to a clean
+        state with a good saveStatus and no required extra functionality in the client."""
+
+        logger.info("instructing the NSM-Server to send Save to ourselves.")
+        if "server-control" in self.serverFeatures:
+            #message = _OutgoingMessage("/nsm/server/save") # "Save All" Command.
+            message = _OutgoingMessage("/nsm/gui/client/save")
+            message.add_arg("{}".format(self.ourClientId))
+            self.sock.sendto(message.build(), self.nsmOSCUrl)
+        else:
+            logger.warning("...but the NSM-Server does not support server control. Server only supports: {}".format(self.serverFeatures))
+
+    def changeLabel(self, label:str):
+        """This function is implemented because it is provided by NSM. However, it does not much.
+        The message gets received but is not saved.
+        The official NSM GUI uses it but then does not save it.
+        We would have to send it every startup ourselves.
+
+        This is fine for us as clients, but you need to provide a GUI field to enter that label."""
+        logger.info("Telling the NSM-Server that our label is now " + label)
+        message = _OutgoingMessage("/nsm/client/label")
+        message.add_arg(label)  #s:label
+        self.sock.sendto(message.build(), self.nsmOSCUrl)
+
+    def broadcast(self, path:str, arguments:list):
+        """/nsm/server/broadcast s:path [arguments...]
+        We, as sender, will not receive the broadcast back.
+
+        Broadcasts starting with /nsm are not allowed and will get discarded by the server
+        """
+        if path.startswith("/nsm"):
+            logger.warning("Attempted broadbast starting with /nsm. Not allwoed")
+        else:
+            logger.info("Sending broadcast " + path + repr(arguments))
+            message = _OutgoingMessage("/nsm/server/broadcast")
+            message.add_arg(path)
+            for arg in arguments:
+                message.add_arg(arg)  #type autodetect
+            self.sock.sendto(message.build(), self.nsmOSCUrl)
+
+    def importResource(self, filePath):
+        """aka. import into session
+
+        ATTENTION! You will still receive an absolute path from this function. You need to make
+        sure yourself that this path will not be saved in your save file, but rather use a place-
+        holder that gets replaced by the actual session path each time. A good point is after
+        serialisation. search&replace for the session prefix ("ourPath") and replace it with a tag
+        e.g. <sessionDirectory>. The opposite during load.
+        Only such a behaviour will make your session portable.
+
+        Do not use the following pattern: An alternative that comes to mind is to only work with
+        relative paths and force your programs workdir to the session directory. Better work with
+        absolute paths internally .
+
+        Symlinks given path into session dir and returns the linked path relative to the ourPath.
+        It can handles single files as well as whole directories.
+
+        if filePath is already a symlink we do not follow it. os.path.realpath or os.readlink will
+        not be used.
+
+        Multilayer links may indicate a users ordering system that depends on
+        abstractions. e.g. with mounted drives under different names which get symlinked to a
+        reliable path.
+
+        Basically do not question the type of our input filePath.
+
+        tar with the follow symlink option has os.path.realpath behaviour and therefore is able
+        to follow multiple levels of links anyway.
+
+        A hardlink does not count as a link and will be detected and treated as real file.
+
+        Cleaning up a session directory is either responsibility of the user
+        or of our client program. We do not provide any means to unlink or delete files from the
+        session directory.
+        """
+
+        #Even if the project was not saved yet now it is time to make our directory in the NSM dir.
+        if not os.path.exists(self.ourPath):
+            os.makedirs(self.ourPath)
+
+        filePath = os.path.abspath(filePath) #includes normalisation
+        if not os.path.exists(self.ourPath):raise FileNotFoundError(self.ourPath)
+        if not os.path.isdir(self.ourPath): raise NotADirectoryError(self.ourPath)
+        if not os.access(self.ourPath, os.W_OK): raise PermissionError("not writable", self.ourPath)
+
+        if not os.path.exists(filePath):raise FileNotFoundError(filePath)
+        if os.path.isdir(filePath): raise IsADirectoryError(filePath)
+        if not os.access(filePath, os.R_OK): raise PermissionError("not readable", filePath)
+
+        filePathInOurSession = os.path.commonprefix([filePath, self.ourPath]) == self.ourPath
+        linkedPath = os.path.join(self.ourPath, os.path.basename(filePath))
+        linkedPathAlreadyExists = os.path.exists(linkedPath)
+
+        if not os.access(os.path.dirname(linkedPath), os.W_OK): raise PermissionError("not writable", os.path.dirname(linkedPath))
+
+
+        if filePathInOurSession:
+            #loadResource from our session dir. Portable session, manually copied beforehand or just loading a link again.
+            linkedPath = filePath #we could return here, but we continue to get the tests below.
+            logger.info(f"tried to import external resource {filePath} but this is already in our session directory. We use this file directly instead. ")
+
+        elif linkedPathAlreadyExists and os.readlink(linkedPath) == filePath:
+            #the imported file already exists as link in our session dir. We do not link it again but simply report the existing link.
+            #We only check for the first target of the existing link and do not follow it through to a real file.
+            #This way all user abstractions and file structures will be honored.
+            linkedPath = linkedPath
+            logger.info(f"tried to import external resource {filePath} but this was already linked to our session directory before. We use the old link: {linkedPath} ")
+
+        elif linkedPathAlreadyExists:
+            #A new file shall be imported but it would create a linked name which already exists in our session dir.
+            #Because we already checked for a new link to the same file above this means actually linking a different file so we need to differentiate with a unique name
+            firstpart, extension = os.path.splitext(linkedPath)
+            uniqueLinkedPath = firstpart + "." + uuid4().hex + extension
+            assert not os.path.exists(uniqueLinkedPath)
+            os.symlink(filePath, uniqueLinkedPath)
+            logger.info(self.ourClientNameUnderNSM + f":pysm2: tried to import external resource {filePath} but potential target link {linkedPath} already exists. Linked to {uniqueLinkedPath} instead.")
+            linkedPath = uniqueLinkedPath
+
+        else: #this is the "normal" case. External resources will be linked.
+            assert not os.path.exists(linkedPath)
+            os.symlink(filePath, linkedPath)
+            logger.info(f"imported external resource {filePath} as link {linkedPath}")
+
+        assert os.path.exists(linkedPath), linkedPath
+        return linkedPath
+
+class NullClient(object):
+    """Use this as a drop-in replacement if your program has a mode without NSM but you don't want
+    to change the code itself.
+    This was originally written for programs that have a core-engine and normal mode of operations
+    is a GUI with NSM but they also support commandline-scripts and batch processing.
+    For these you don't want NSM."""
+
+    def __init__(self, *args, **kwargs):
+        self.realClient = False
+        self.ourClientNameUnderNSM = "NSM Null Client"
+
+    def announceSaveStatus(self, *args):
+        pass
+
+    def announceGuiVisibility(self, *args):
+        pass
+
+    def reactToMessage(self):
+        pass
+
+    def importResource(self):
+        return ""
+
+    def serverSendExitToSelf(self):
+        quit()