liblsl 1.1.2
liblsl: ^1.1.2 copied to clipboard
A Dart (and Flutter) native library for working with Lab Streaming Layer (LSL / liblsl).
liblsl: Lab Streaming Layer for Dart and Flutter #
liblsl is a Dart interface to liblsl,
the C++ library of Lab Streaming Layer
(LSL). LSL transmits time series, such as EEG and other physiological
signals, events and markers, between applications and devices on a local
network, and provides the clock synchronisation needed to align them. With
this package a Dart or Flutter application can send and receive LSL streams
and interoperate with any other LSL software, for example LabRecorder.
The package covers the whole C API. The C++ library is compiled from source by the Dart build hooks and called through FFI, so no separately installed library is required.
API documentation · Guide: streaming data between two devices · JOSS paper · Review and test guide
Platforms #
Windows, macOS, Linux, Android and iOS are supported. The package has also been run on the Meta Quest 2 (Android) and, with Dart only, on a Raspberry Pi 4 (Raspberry Pi OS).
The web is not supported, because liblsl is a native library. For browser
applications, the WebSocket transport of
peer_coordinator and the LSL
bridge of lsl_tools relay streams to
and from the browser.
Installation #
dart pub add liblsl
A C++ toolchain is required, since the library is compiled when the application is first built. On Debian-based Linux:
sudo apt install build-essential clang llvm
Flutter applications use the package in the same way. The
liblsl_test application is a working
Flutter example for every supported platform.
Usage #
A sender describes its stream, opens an outlet and pushes samples:
import 'package:liblsl/lsl.dart';
final info = await LSL.createStreamInfo(
streamName: 'GuideStream',
streamType: LSLContentType.custom('Example'),
channelCount: 2,
sampleRate: 10.0,
channelFormat: LSLChannelFormat.double64,
sourceId: 'guide-sender-1',
);
final outlet = await LSL.createOutlet(streamInfo: info);
outlet.pushSample([1.0, 2.0]);
A receiver resolves the stream, opens an inlet and pulls samples. Each sample has a timestamp on the sender's clock, and the time correction maps it onto the receiver's clock:
final streams = await LSL.resolveStreamsByProperty(
property: LSLStreamProperty.name,
value: 'GuideStream',
waitTime: 10.0,
minStreamCount: 1,
);
final inlet = await LSL.createInlet<double>(streamInfo: streams.first);
final offset = await inlet.getTimeCorrection(timeout: 5.0);
final sample = await inlet.pullSample(timeout: 2.0);
final sentAt = sample.timestamp + offset;
Native resources are released explicitly:
inlet.destroy();
streams.destroy();
outlet.destroy();
info.destroy();
These fragments follow example/send.dart and
example/receive.dart, which the
guide explains step by
step. example/liblsl_example.dart shows
several streams in one program.
Isolates and direct calls #
By default every outlet and inlet runs in its own isolate, so that sending and receiving do not block the main isolate. Performance can degrade when the number of streams exceeds the number of CPU cores.
An outlet or inlet created with useIsolates: false is called directly
through FFI. The *Sync methods, such as pushSampleSync and
pullSampleSync, are available in this mode and have no asynchronous
overhead, which suits code with strict timing requirements.
Receiving samples as they arrive #
sampleStream() delivers each sample when it arrives, together with the
local clock at that moment. It uses one isolate per inlet, which waits
inside the native pull call, so no polling interval is added to the receive
time. chunkStream() is the chunked equivalent.
final subscription = inlet.sampleStream().listen((sample) {
final latency = sample.receivedClock - (sample.timestamp + offset);
});
await subscription.cancel();
The stream reports an LSLSampleListenerException if its isolate ends for
any reason other than cancellation. A listener that is slower than the
stream accumulates a queue; the onBacklog and maxBacklog parameters
report and bound it.
Chunked transfer #
When throughput matters more than per-sample latency, push and pull whole blocks of samples with one native call. Both a convenience list form and a flat typed-data fast path (single memmove, no per-element conversion) are available:
// List form: one List per sample, channelCount values each.
await outlet.pushChunk([
[1.0, 2.0],
[3.0, 4.0],
]);
// Typed fast path: flat, sample-major (Float32List for float32 streams,
// Int16List for int16, ...). Optional per-sample timestamps.
final data = Float32List.fromList([1.0, 2.0, 3.0, 4.0]);
await outlet.pushChunkTyped(data);
// Pulling: with the default timeout of 0.0 this returns everything already
// buffered (up to maxSamples). A nonzero timeout keeps pulling until
// maxSamples samples arrive or the timeout expires — it does not return
// early once some data is there.
final chunk = await inlet.pullChunk(maxSamples: 512);
print('${chunk.sampleCount} samples, first ts ${chunk.timestamps.firstOrNull}');
final typed = await inlet.pullChunkTyped(maxSamples: 512);
final Float32List flat = typed.data as Float32List;
In direct mode (useIsolates: false) the *Sync variants
(pushChunkSync, pullChunkTypedSync, and the zero-copy
pullChunkPointerSync) skip all async overhead. String streams support
the list forms (pushChunk/pullChunk) but not the typed ones.
Explicit timestamps and pushthrough #
By default a pushed sample is stamped with the current LSL.localClock().
When the data was captured earlier (e.g. an event detected a few ms ago, or
samples read from a device buffer), pass the capture time instead:
final capturedAt = LSL.localClock() - latency;
await outlet.pushSample(['stimulus_onset'], timestamp: capturedAt);
// pushthrough: false lets liblsl batch samples; true (liblsl's default)
// sends immediately. Works with or without a timestamp.
await outlet.pushSample([1.0, 2.0], pushthrough: false);
await outlet.pushChunk(samples, timestamps: perSampleTimes, pushthrough: true);
Binary string samples #
String channels are NUL-terminated in the normal API. To send arbitrary bytes
(including 0x00) on a string stream, use the binary variants:
await outlet.pushSampleBytes([Uint8List.fromList([0x01, 0x00, 0x02])]);
final sample = await inlet.pullSampleBytes(timeout: 1.0); // LSLSample<Uint8List>
await outlet.pushChunkBytes(listOfSamplesOfUint8Lists);
final chunk = await inlet.pullChunkBytes(maxSamples: 64);
The binary chunk pull keeps pulling until maxSamples samples have arrived
or timeout expires; use timeout: 0.0 to take only what is already
buffered.
Transport options (sync/blocking transfer, buffer units) #
Outlets and inlets accept a set of LSLTransportOptions applied at
creation:
final outlet = await LSL.createOutlet(
streamInfo: info,
transportOptions: {LSLTransportOptions.syncBlocking},
);
final inlet = await LSL.createInlet<double>(
streamInfo: streams[0],
// maxBuffer now means samples, not seconds:
maxBuffer: 1000,
transportOptions: {
LSLTransportOptions.bufsizeInSamples,
LSLTransportOptions.syncBlocking,
},
);
syncBlocking switches the outlet to zero-copy blocking socket writes:
every push hands the buffer directly to each connected consumer and only
returns once the OS has accepted the data for all of them. This reduces CPU
usage and latency jitter for high-bandwidth streams, but:
- each push blocks for as long as the slowest consumer needs (in direct mode this stalls the calling isolate);
- it is not compatible with string-format streams (an
ArgumentErroris thrown at creation); - only one thread/isolate may push at a time;
bufsizeInSamples/bufsizeInThousandthschange the unit ofmaxBuffer(they are mutually exclusive).
Benchmarking #
A benchmark suite compares the transport modes and operations; see
benchmark/README.md. It runs in CI on every commit
to main and on every release, and the results over time are charted in
the benchmark history.
dart run benchmark/bin/liblsl_benchmark.dart --smoke
Direct FFI usage #
The generated FFI bindings can be used directly by importing native_liblsl.dart.
import 'package:liblsl/native_liblsl.dart';
// Create a simple stream info
final streamNamePtr = "TestStream".toNativeUtf8().cast<Char>();
final streamTypePtr = "EEG".toNativeUtf8().cast<Char>();
final sourceIdPtr = "TestSource".toNativeUtf8().cast<Char>();
final streamInfo = lsl_create_streaminfo(
streamNamePtr,
streamTypePtr,
1, // One channel
100.0, // 100Hz sample rate
lsl_channel_format_t.cft_string, // String format
sourceIdPtr,
);
// Create outlet
final outlet = lsl_create_outlet(streamInfo, 0, 1);
// Create a string sample (as an array of strings)
final sampleStr = "Test Sample".toNativeUtf8().cast<Char>();
final stringArray = malloc<Pointer<Char>>(1);
stringArray[0] = sampleStr;
// Push the sample
final result = lsl_push_sample_str(outlet, stringArray);
// Assert the result
expect(result, 0); // 0 typically means success
// Clean up
lsl_destroy_outlet(outlet);
lsl_destroy_streaminfo(streamInfo);
streamNamePtr.free();
streamTypePtr.free();
sourceIdPtr.free();
sampleStr.free();
stringArray.free();
Network and platform notes #
Multicast #
LSL discovers streams with multicast UDP. Managed switches, routers and firewalls can block multicast, in which case streams on other devices are not found. The network and the firewall of each device must permit it.
Networks without multicast #
LSL can be used without multicast when the IP addresses of the devices are known. The configuration is applied before any other LSL call, and on every device. The options correspond to the LSL configuration file.
LSL.setConfigContent(LSLApiConfig(knownPeers: ['10.0.0.100', '10.0.0.101']));
Android #
An application requires the INTERNET, CHANGE_WIFI_MULTICAST_STATE, ACCESS_NETWORK_STATE, and ACCESS_WIFI_STATE permissions in its AndroidManifest.xml, for the multicast UDP communication that LSL uses.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- ... other AndroidManifest.xml nodes -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
</manifest>
iOS #
Multicast networking on iOS requires the entitlement
com.apple.developer.networking.multicast,
which Apple grants on request to paid developer accounts through the
Multicast Networking Entitlement Request page.
Without it, LSL cannot be used on iOS.
The following entries are also required in Info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- ... other Info.plist nodes -->
<key>NSBonjourServices</key>
<array>
<string>liblsl._tcp</string>
<string>liblsl._udp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow LSL to find other devices and communicate</string>
</dict>
</plist>
macOS and Linux: open file limit #
Every outlet, inlet and resolver uses several sockets, and the default limit on open files per process (256 on macOS, 1024 on most Linux desktops) runs out with a few dozen streams: liblsl then logs Too many open files and fails to create outlets and inlets.
So on macOS and Linux, loading liblsl raises the process's soft open-file limit:
- A soft limit of 65536 or more is left untouched.
- Otherwise it is raised as far as the system allows (the hard limit, and on macOS
kern.maxfilesperproc), up to 1048576. The hard limit is never changed and the limit is never lowered. - If the system refuses (e.g. a sandbox or device management), liblsl prints a warning to stderr and carries on; the limit can then be raised with
ulimit -n. - Set the environment variable
LIBLSL_DART_NO_RLIMIT=1to leave the limit alone.
The limit is per process, so child processes that the application starts inherit the raised limit.
macOS: network settings #
macOS's default TCP buffers are small, which slows high-rate transfers, and maxfiles bounds how far the open-file limit can be raised. For demanding setups:
sudo sysctl -w net.inet.tcp.mssdflt=1420
sudo sysctl -w net.inet.tcp.win_scale_factor=7
sudo sysctl -w net.inet.tcp.sendspace=861275
sudo sysctl -w net.inet.tcp.recvspace=861275
sudo sysctl -w net.inet.tcp.autosndbufmax=8388608
sudo sysctl -w net.inet.tcp.autorcvbufmax=8388608
sudo sysctl -w net.inet.ip.portrange.first=32768
sudo launchctl limit maxfiles 65536 200000
These settings last until reboot.
Testing #
dart test
The tests need a few thousand open files; the limit is raised automatically (see the open file limit). The review and test guide describes further options.
Contributing and support #
Contribution guidelines are in CONTRIBUTING.md, and participants are expected to uphold the Code of Conduct. SUPPORT.md describes where to ask questions and discuss features. Security vulnerabilities are reported as described in SECURITY.md.
License #
This project is licensed under the MIT License; see LICENSE.
Acknowledgments #
- liblsl by Christian A. Kothe and contributors, the LSL library
- The Dart programming language by Google