libLSL

Lab Streaming Layer Part 2

2024/07/11

libLSL Timing

I’ve been looking at Lab Streaming Layer (LSL) lately. It’s a relatively easy way to send and read data over a local network. In my previous post I worked with one stream. In this post I use multiple streams. Two Raspberry Pis streaming data with outlets and a single PC reading both streams with an inlet.

One problem with having multiple networked sources is time-synchronization. Devices clocks may not be synchronized to a common time server and drift can occur between time updates. If the time between clock updates is sufficiently large, the data streams will be out of sync.

LSL’s docs state that it does not perform time synchronization by default. Instead it gives you information to synchronize it yourself. This information consists of

  1. Timestamps for each sample (or chunk of samples)
  2. Clock offset measurements between the stream-outlet and the stream inlet.

To try this out, I’ll have two stream outlets running on two different Raspberry Pis with slightly different system times. A third machine will have an inlet listening to both outlets. This third machine will check the timestamps of both stream samples and their offset correction values.

Raspberry Pi GPIO and Outlet Streams

To test out the time correction, I’d like the two network sources to send out a message at the same time. One way to do this is to send the message on a common signal.

The built in GPIO pins on a raspberry pi make it easy to detect when a switch is closed. A single switch will be connected to both devices simultaneously. When the switch is closed the two outlet scripts will send a message with their system times through LSL.

Increasing the Time Offset

To make the time difference between machines larger, I’m stopping time synchronization on one of the Pis:

sudo systemctl stop systemd-timesyncd
sudo systemctl disable systemd-timesyncd

and manually setting the time to be over a minute behind the other.

ggallard@huginn:~ $ sudo date -s "7 JUL 2024 15:30"

The Trigger Circuit

The circuit is very simple. One contact of the switch is connected to GPIO-26 on both Raspberry Pis. The other contact is connected to ground on both.

py circuit When the switch is closed pin 26 gets pulled low, which can easily be detected by a python script.

The Outlet Script

I’ve run into problems with the default python GPIO library on Raspbian so instead I’ve installed rpi-lgpio

pip3 install rpi-lgpio

The code to detect the pin and send a sample is simple. I’ll use wait_for_edte() to wait for a GPIO pin to fall and get the system and LSL local clock times immediately.

        GPIO.wait_for_edge(BUTTON_GPIO, GPIO.FALLING)
        # python timestamp
        ts = time.time()
        # LSL timestamp
        timestamp = local_clock()

From there, the timestamps can be converted to a string and sent through the outlet

        timestring =  str(timestamp) + " : " + str(ts) + " - " + datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
        string_outlet.push_sample([timestring])

The complete code is:

#!/usr/bin/env python3

import RPi.GPIO as GPIO
import time
import datetime

from pylsl import StreamInfo, StreamOutlet, local_clock

BUTTON_GPIO = 26

if __name__ == '__main__':
    string_info = StreamInfo('TriggerStream', 'Custom', 1, 0, 'string')
    string_outlet = StreamOutlet(string_info)

    GPIO.setmode(GPIO.BCM)
    GPIO.setup(BUTTON_GPIO, GPIO.IN, pull_up_down=GPIO.PUD_UP)

    while True:
        GPIO.wait_for_edge(BUTTON_GPIO, GPIO.FALLING)
        # python timestamp
        ts = time.time()
        # LSL timestamp
        timestamp = local_clock()
        print(timestamp)
        timestring =  str(timestamp) + " : " + str(ts) + " - " + datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
        print(timestring)
        string_outlet.push_sample([timestring])
        time.sleep(0.2) # delay to filter/debounce

Pressing the switch triggers both instances and they publish their system times

Inlet Streams and time_correction()

The third machine reads samples with the inlet’s pull_sample() method.

    double timestamp = inlet->pull_sample(sample, 1.0);

The time offsets are also read from the inlet using the time_correction() method.

    double timeCorrection = inlet->time_correction();

This value can be added to the sample timestamp to synchronize the time to the local machine.

    std::cout << "Timestamp: " << timestamp << " time correction: " << timeCorrection <<  " corrected time: " << timestamp + timeCorrection << ":\n";

The completed code is here:

#include <iostream>
#include <lsl_cpp.h>
#include <vector>
#include <thread>

void process_inlet(lsl::stream_inlet* inlet) {
    try {
        while (true) {
            // Buffer to hold the received string data
            std::vector<std::string> sample(1);

            // Pull sample from the inlet with a timeout of 1 second

            double timestamp = inlet->pull_sample(sample, 1.0);
            double localTimestamp = lsl::local_clock();
            std::cout << "Local: " << localTimestamp << std::endl;
            if (timestamp != 0.0) { // Check if a sample was received
                // Print the received data
                double timeCorrection = inlet->time_correction();
                std::cout << "Timestamp: " << timestamp << " time correction: " << timeCorrection <<  " corrected time: " << timestamp + timeCorrection << ":\n";
                for (size_t i = 0; i < sample.size(); ++i) {
                    std::cout << "Channel " << i << ": " << sample[i] << std::endl;
                }
            }

            // Sleep for a short duration to simulate processing time
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
    }
    catch (std::exception& e) {
        std::cerr << "Error in thread: " << e.what() << std::endl;
    }
}

int main(int argc, char** argv) {
    try {
        // Resolve all streams with the given name and type
        std::vector<lsl::stream_info> results = lsl::resolve_stream("name", "TriggerStream",2);
        std::cout << "Found " << results.size() << " streams" << std::endl;

        if (results.empty()) {
            std::cerr << "No stream found." << std::endl;
            return 1;
        }

        // Create inlets for each stream and launch threads to process them
        std::vector<lsl::stream_inlet*> inlets;
        std::vector<std::thread> threads;
        for (const auto& info : results) {
            lsl::stream_inlet* inlet = new lsl::stream_inlet(info);
            inlets.push_back(inlet);
            std::cout << "The stream's XML meta-data is: \n" << info.as_xml() << std::endl;

            // Launch a thread to process the inlet
            threads.emplace_back(process_inlet, inlet);
        }

        // Wait for all threads to finish (they won't in this infinite loop scenario)
        for (auto& thread : threads) {
            thread.join();
        }

        // Clean up dynamically allocated inlets
        for (auto& inlet : inlets) {
            delete inlet;
        }
    }
    catch (std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
        return 1;
    }

    return 0;
}

Each stream is read in its own thread and values are printed out as soon as they come in.

Output looks pretty good:

2024-07-07 17:00:32.133 (   0.014s) [        B70AE6C2]      netinterfaces.cpp:36    INFO| netif '{EDD8A4F3-BBDB-4D8B-A3FC-25E7B583AF66}' (status: 1, multicast: 1
2024-07-07 17:00:32.135 (   0.014s) [        B70AE6C2]      netinterfaces.cpp:58    INFO|       IPv6 ifindex 19
2024-07-07 17:00:32.137 (   0.016s) [        B70AE6C2]      netinterfaces.cpp:36    INFO| netif '{7BB3B53A-88F1-43FF-A3DF-9A4F99290F23}' (status: 1, multicast: 1
2024-07-07 17:00:32.137 (   0.016s) [        B70AE6C2]      netinterfaces.cpp:58    INFO|       IPv6 ifindex 12
2024-07-07 17:00:32.138 (   0.017s) [        B70AE6C2]      netinterfaces.cpp:36    INFO| netif '{D6835755-BA90-11E7-8A1D-806E6F6E6963}' (status: 1, multicast: 1
2024-07-07 17:00:32.138 (   0.017s) [        B70AE6C2]      netinterfaces.cpp:58    INFO|       IPv6 ifindex 1
2024-07-07 17:00:32.139 (   0.018s) [        B70AE6C2]      netinterfaces.cpp:36    INFO| netif '{36732199-3D64-4C8E-8567-B088A6FDCB3B}' (status: 1, multicast: 1
2024-07-07 17:00:32.140 (   0.019s) [        B70AE6C2]      netinterfaces.cpp:58    INFO|       IPv6 ifindex 38
2024-07-07 17:00:32.140 (   0.019s) [        B70AE6C2]         api_config.cpp:270   INFO| Loaded default config
Found 2 streams
2024-07-07 17:00:32.227 (   0.106s) [        B70AE6C2]             common.cpp:65    INFO| git:/branch:/build:/compiler:MSVC-19.35.32216.1/link:SHARED
The stream's XML meta-data is:
<?xml version="1.0"?>
<info>
        <name>TriggerStream</name>
        <type>Custom</type>
        <channel_count>1</channel_count>
        <channel_format>string</channel_format>
        <source_id>str_source_00001</source_id>
        <nominal_srate>0.000000000000000</nominal_srate>
        <version>1.100000000000000</version>
        <created_at>5053.956333853000</created_at>
        <uid>b4c18056-a4f8-43f9-8b71-a6b10c681057</uid>
        <session_id>default</session_id>
        <hostname>huginn</hostname>
        <v4address />
        <v4data_port>16572</v4data_port>
        <v4service_port>16572</v4service_port>
        <v6address />
        <v6data_port>16573</v6data_port>
        <v6service_port>16573</v6service_port>
        <desc />
</info>

The stream's XML meta-data is:
<?xml version="1.0"?>
<info>
        <name>TriggerStream</name>
        <type>Custom</type>
        <channel_count>1</channel_count>
        <channel_format>string</channel_format>
        <source_id>str_source_00002</source_id>
        <nominal_srate>0.000000000000000</nominal_srate>
        <version>1.100000000000000</version>
        <created_at>5053.005858017000</created_at>
        <uid>fb29d72e-362b-4a79-a8dc-8e6a56d48832</uid>
        <session_id>default</session_id>
        <hostname>muninn</hostname>
        <v4address />
        <v4data_port>16572</v4data_port>
        <v4service_port>16572</v4service_port>
        <v6address />
        <v6data_port>16573</v6data_port>
        <v6service_port>16573</v6service_port>
        <desc />
</info>

The actual data strings show the times on the outlet machines are off by over a minute ( 16:53:01 vs 16:51:45 )

local times

I pressed the switch several times and got the following results:

py circuit

The first thing that stands out for me is the difference between LSL’s local clock on Windows and Linux

On Windows I call `lsl::local_clock()

        double localTimestamp = lsl::local_clock();
       std::cout << std::setprecision(10) << "Local: " << localTimestamp << std::endl;

with output times in the 690K range.

On Linux I call pylsl’s local_clock

from pylsl import StreamInfo, StreamOutlet, local_clock

...
        timestamp = local_clock()
        print(timestamp)
        timestring = str(timestamp) + " : " + str(ts) + " - " + datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')

Which has times in around 18K. Which is very different from Windows. Their FAQ covers this.

LSL’s lsl_local_clock() function uses std::chrono::steady_clock::now().time_since_epoch(). This returns the number of seconds from an arbitrary starting point. The starting point is platform-dependent – it may be close to UNIX time, or the last reboot – and LSL timestamps cannot be transformed naively to wall clock time without special effort.

The documentation also points out that

In general, it is not possible to synchronize LSL streams with non-LSL clocks (e.g., wall clock, UNIX time, device without an LSL integration) unless there is a separate solution for this.

That’s OK though. What really matters is the corrected time from all sources are as close as possible. In my case, the times get translated to the Windows time value (as it’s the listener).

Local: 690269.0987
Local: 690269.0996
Local: 690269.7689
Timestamp: 18991.65743 time correction: 671278.1102 corrected time: 690269.7676:
Channel 0: 18991.656931765 : 1720403011.9195507 - 2024-07-07 20:43:31
Local: 690269.7692
Timestamp: 18981.62148 time correction: 671288.1463 corrected time: 690269.7678:
Channel 0: 18981.620796655 : 1720403088.4776466 - 2024-07-07 20:44:48

The correct values are very close: 690269.7676 vs 690269.7678. Much better than the minute difference between the two Raspberry Pis.

About Me

Greg Gallardo

I'm a software developer and sys-admin in Iowa. I use C++, C#, Java, Swift, Python, JavaScript and TypeScript in various projects. I also maintain Windows and Linux systems on-premise and in the cloud ( Linode, AWS, and Azure )

Github

Mastodon

YouTube

About you

IP Address: 3.146.37.242

User Agent: Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)

Language:

Latest Posts

Iowa City Weather

Today

43 ˚F / 43 ˚F

Friday

47 ˚F / 40 ˚F

Saturday

49 ˚F / 36 ˚F

Sunday

48 ˚F / 32 ˚F

Monday

45 ˚F / 32 ˚F

Tuesday

37 ˚F / 23 ˚F

New Year's Day

32 ˚F / 20 ˚F