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
- Timestamps for each sample (or chunk of samples)
- 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.
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
= time.time()
ts # LSL timestamp
= local_clock() timestamp
From there, the timestamps can be converted to a string and sent through the outlet
= str(timestamp) + " : " + str(ts) + " - " + datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
timestring 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
= 26
BUTTON_GPIO
if __name__ == '__main__':
= StreamInfo('TriggerStream', 'Custom', 1, 0, 'string')
string_info = StreamOutlet(string_info)
string_outlet
GPIO.setmode(GPIO.BCM)=GPIO.PUD_UP)
GPIO.setup(BUTTON_GPIO, GPIO.IN, pull_up_down
while True:
GPIO.wait_for_edge(BUTTON_GPIO, GPIO.FALLING)# python timestamp
= time.time()
ts # LSL timestamp
= local_clock()
timestamp print(timestamp)
= str(timestamp) + " : " + str(ts) + " - " + datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
timestring print(timestring)
string_outlet.push_sample([timestring])0.2) # delay to filter/debounce time.sleep(
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) {
new lsl::stream_inlet(info);
lsl::stream_inlet* inlet =
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'{7BB3B53A-88F1-43FF-A3DF-9A4F99290F23}' (status: 1, multicast: 1
2024-07-07 17:00:32.137 ( 0.016s) [ B70AE6C2] netinterfaces.cpp:36 INFO| netif
2024-07-07 17:00:32.137 ( 0.016s) [ B70AE6C2] netinterfaces.cpp:58 INFO| IPv6 ifindex 12'{D6835755-BA90-11E7-8A1D-806E6F6E6963}' (status: 1, multicast: 1
2024-07-07 17:00:32.138 ( 0.017s) [ B70AE6C2] netinterfaces.cpp:36 INFO| netif
2024-07-07 17:00:32.138 ( 0.017s) [ B70AE6C2] netinterfaces.cpp:58 INFO| IPv6 ifindex 1'{36732199-3D64-4C8E-8567-B088A6FDCB3B}' (status: 1, multicast: 1
2024-07-07 17:00:32.139 ( 0.018s) [ B70AE6C2] netinterfaces.cpp:36 INFO| netif
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's XML meta-data is:
The stream<?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:
"1.0"?>
<?xml version=
<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 )
I pressed the switch several times and got the following results:
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
...= local_clock()
timestamp print(timestamp)
= str(timestamp) + " : " + str(ts) + " - " + datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') timestring
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.