BrainAccessLslOutlet
An LSL StreamOutlet for one BrainAccess device, fed EegChunks, following the BrainAccess Board LSL contract v1: a double64 stream of type EEG at the device sample rate whose header carries the full StreamMetadata (identity, serial number, electrode positions and hardware, reference/ground, versions, …) in the XDF convention, so Board, LabRecorder, pyxdf and MNE read it without BrainAccess code.
Column order (see lslContractChannels): [EEG…][contact_<label>…][Accel_x,y,z]?[Gyro_x,y,z]? [Digital]?[Sample][Streaming][Battery]. Digital (type TRIG) is the digital-input level, 0 or 1, present only when the stream was started with digital input. IMU columns are in m/s² and deg/s. contact_* is 1 when the electrode is connected, 0 otherwise. Sample is the device sample counter (firstSampleIndex + s); Streaming is 1 for samples that arrived over the air and 0 for zero-filled gap samples, so a jump in Sample or a 0 marks a gap. Battery is the percentage, or -1 while unknown. All columns are double64, so Sample is exact for every value of the device's 32-bit counter.
Timestamps. Without sampleTimes liblsl stamps each chunk when it is pushed, which is when the packet reached the phone: tens of milliseconds after the samples were taken, and jittering by several. With sampleTimes — com.neurotec.brainaccess.core.DeviceSession.sampleHostTimeNs, which streamToLsl passes — every sample is stamped with the host time the headset's own clock puts it at (liblsl's local_clock() is System.nanoTime() on Android), about a millisecond from the truth. The function takes a device sample counter and returns host nanoseconds, or null while it cannot say (the first seconds of a stream, firmware before 3.8.0, impedance mode). Chunks are held while it answers null and released in order once it answers; after 5 s without an answer they are pushed as without it (stamped at push time), with one logcat line.
metadata must carry exactly the channel list lslContractChannels yields for its own shape (EEG labels, accelerometer/gyroscope presence, impedance mode, Digital column); anything else is rejected.
Stream identity: use contractStreamName for streamName and the device's advertised BLE name (com.neurotec.brainaccess.core.DeviceSession.name) for sourceId.
NOTE: liblsl is provided by the consumer at runtime (this module compiles against it compileOnly; the native library ships separately in the distribution zip). Constructing this class on a classpath without liblsl + its .so will throw UnsatisfiedLinkError/ NoClassDefFoundError — guard at the call site if LSL is optional in your app.
push is not thread-safe (drive it from a single coroutine/thread), but close is idempotent and safe to call more than once or concurrently, so consumer teardown paths (user toggle and link-drop teardown) may overlap. Chunks still held when the outlet closes are dropped.