Sharing LSL streams over the network: bridge and relay

Lab Streaming Layer finds streams with multicast, so it only works where multicast reaches: usually one local network. A web browser can't use LSL at all, because it has no raw sockets. The LSL bridge in lsl_tools carries streams over a WebSocket instead:

You can run it from the LSL Viewer desktop app (no command line needed) or with the lsl command line tool.

Contents

Which setup do I need?

You want to… Run this where the streams are Clients connect with
See lab streams in a browser lsl share (or viewer: Share) Viewer: LSL > Connect to an LSL bridge…
Get lab streams into LSL on another network lsl share lsl bridge ws://host:8765 (or viewer: Publish its streams on LSL here)
Get a browser's streams (serial device, replayed XDF) into LSL lsl share --accept (or viewer: Share + accept) Viewer in the browser: Forward… or Replay…
Pass streams between browsers, no LSL anywhere lsl relay (or viewer: Relay only) Viewers: Connect, then Forward/Replay and View
Send lab streams out to a relay on a server (the lab accepts no incoming connections) lsl publish wss://relay… in the lab Anyone connected to the relay
Setup 1   [LSL network A] ──lsl share──▶ ws ──▶ lsl bridge ──▶ [LSL network B]
                                          └───▶ browser (LSL Viewer on the web)

Setup 2   browser ──publish──▶ ws ──▶ lsl share --accept ──▶ [LSL on that computer]
                                                        └──▶ other clients

Setup 3   browser A ──publish──▶ ws ──▶ lsl relay ──▶ ws ──▶ browser B, C, lsl bridge…
          [LSL lab] ──lsl publish──▶ ┘

Streams that clients publish are always shared with every other client. On a share or share + accept server they also become ordinary LSL streams on that computer. A relay only passes them between clients, so it doesn't need LSL.

Getting the tools

In the examples below, lsl means whichever of these you use.

Quick start: view lab streams in a browser

On a computer on the lab network (where the LSL streams are):

lsl share --token choose-a-secret
# Sharing EEG, Markers on port 8765 (address 0.0.0.0)

Or in LSL Viewer: LSL > Share or relay streams…, choose Share, set a token, then click Share. The panel lists the addresses to connect to.

Then, in a browser, open LSL Viewer:

Click View next to a stream to open it in a tab.

Setup 1: streams from one LSL network to another

On network A (where the streams are):

lsl share -s EEG -s Markers --token s3cret

On network B:

lsl bridge ws://lab-pc.example.org:8765 --token s3cret --suffix " (lab)"

Port 8765 (or the one you pick with -p) must be reachable from network B: open it in the firewall, forward it on the router, or use a VPN.

Setup 2: a browser publishes into LSL

On the computer that should receive the streams:

lsl share --accept --token s3cret

In the browser, Connect to an LSL bridge… that computer, then:

The streams appear as LSL streams on the --accept computer (look for them with lsl list). Other clients of the bridge see them too.

Setup 3: a relay between browsers

lsl relay --token s3cret

Every client connects to the relay:

A relay needs no LSL on its computer, so it can run on a server with no lab network (next sections). In the viewer, use Share or relay streams… and choose Relay only.

Security: tokens, origins and TLS

The bridge is built for a trusted group, such as a lab or a class. It is not meant to be exposed to the open internet unattended.

Running a relay on a server (wss://)

Browsers on https:// pages, including the hosted viewer, need wss://. The bridge speaks plain ws://. Put a reverse proxy with a certificate in front of it.

On the server:

lsl relay --host 127.0.0.1 --port 8765 --token "$(openssl rand -hex 16)" \
  --allow-origin https://nexusdynamic.org

Caddy gets and renews the certificate itself. In the Caddyfile:

relay.example.org {
    reverse_proxy 127.0.0.1:8765
}

nginx (with a certificate from e.g. certbot):

server {
    listen 443 ssl;
    server_name relay.example.org;
    # ssl_certificate / ssl_certificate_key ...

    location / {
        proxy_pass http://127.0.0.1:8765;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 1h;
        access_log off;   # the token is in the URL
    }
}

Clients then connect to wss://relay.example.org with the token.

To keep the relay running, use a systemd unit such as:

[Service]
ExecStart=/opt/lsl-cli/bin/lsl relay --host 127.0.0.1 --token <token>
Restart=always

To send a lab's LSL streams to the relay, run this on a lab computer. It connects out, so the lab needs no open ports:

lsl publish wss://relay.example.org --token <token> -s EEG -s Markers --rescan 5

Everyone connected to the relay then sees them. lsl bridge wss://relay.example.org elsewhere turns them back into LSL streams.

Timing: clock offsets, drift and latency

A sample keeps the time stamp its origin gave it all the way through a bridge or relay. Nothing on the way rewrites it. Beside the samples, every two seconds, travels the stream's clock chain: one entry per hop, each with

The receiving end adds the hops up and puts time stamps on its own clock, smoothed like an LSL inlet's unless it is told not to. So by default a bridged stream lines up with local ones, and the error bound is the sum of the hops' uncertainties: well under a millisecond per hop on a LAN, a few ms per hop over the internet.

Where to see the chain:

The same chain continues through a peer_coordinator session (WebRTC): LslDataStreamPump sends an inlet's samples through a DataStream and DataStreamInlet receives them as an inlet again, for a BridgeOutlet, an LSL outlet or LslRecorder.fromInlets.

Command reference

lsl share shares LSL streams, and with --accept takes published ones.

Option Default Meaning
-s, --stream all Streams by name or type (* wildcard); repeatable
-p, --port 8765 Port (0: any free port)
--host 0.0.0.0 Address to listen on (127.0.0.1: this computer only)
--token none Clients must give it
--allow-origin any Browser origins allowed; repeatable
--accept off Clients may publish streams: shared with the others and published on LSL here
--[no-]local-outlets on With --accept, publish clients' streams on LSL here
--relay off Same as lsl relay
--rescan off Every this many seconds, also share new matching streams

lsl relay passes streams between clients only. It takes -p, --host, --token and --allow-origin.

lsl publish <ws://host:port> publishes LSL streams from here on a bridge that accepts them (--accept or a relay). It takes -s (default: all), --token and --rescan (see above). It skips streams that came from a bridge, so they don't go round in circles.

lsl bridge <ws://host:port> publishes a bridge's streams on LSL here, and follows them as they come and go.

Option Default Meaning
-s, --stream all Stream names (* wildcard); repeatable
--token none The bridge's token
--suffix none Added to the names published here
--[no-]timing-stream on Publish the BridgeTiming stream (see Timing)
--stats off Every this many seconds, print each stream's clock offset, bound, drift and latency per hop

--peer <address> goes before any command and looks for LSL streams on a computer multicast doesn't reach, e.g. lsl --peer 10.0.0.5 share.

The library API is LslBridgeServer.start, LslBridgeClient.connect and LslBridgeRepublisher.start in package:lsl_tools/lsl_tools.dart. The protocol (JSON control messages and binary sample frames) is documented in lib/src/bridge/protocol.dart.

Limitations

Troubleshooting