node-labstreaminglayer
Version:
Node.js bindings for Lab Streaming Layer (LSL)
524 lines • 19.4 kB
JavaScript
/**
* @fileoverview StreamInfo class and XMLElement class for LSL stream metadata.
*
* This module provides classes for declaring and manipulating stream metadata.
* StreamInfo objects describe the properties of a data stream including its
* name, type, channel count, sampling rate, and data format.
*
* @module streamInfo
* @see {@link https://labstreaminglayer.readthedocs.io/} - LSL Documentation
*/
import { lsl_create_streaminfo, lsl_destroy_streaminfo, lsl_get_name, lsl_get_type, lsl_get_channel_count, lsl_get_nominal_srate, lsl_get_channel_format, lsl_get_source_id, lsl_get_version, lsl_get_created_at, lsl_get_uid, lsl_get_session_id, lsl_get_hostname, lsl_get_desc, lsl_get_xml, lsl_first_child, lsl_last_child, lsl_next_sibling, lsl_next_sibling_n, lsl_previous_sibling, lsl_previous_sibling_n, lsl_parent, lsl_child, lsl_empty, lsl_is_text, lsl_name, lsl_value, lsl_child_value, lsl_child_value_n, lsl_append_child_value, lsl_prepend_child_value, lsl_set_child_value, lsl_set_name, lsl_set_value, lsl_append_child, lsl_prepend_child, lsl_append_copy, lsl_prepend_copy, lsl_remove_child, lsl_remove_child_n, cf_float32, string2fmt } from './lib/index.js';
import { IRREGULAR_RATE } from './util.js';
/**
* FinalizationRegistry for automatic cleanup of StreamInfo objects.
* When a StreamInfo instance is garbage collected, this registry ensures
* the underlying C object is properly freed to prevent memory leaks.
*
* @private
*/
const streamInfoRegistry = new FinalizationRegistry((obj) => {
try {
lsl_destroy_streaminfo(obj);
}
catch (e) {
// Silently ignore cleanup errors - the object may already be destroyed
}
});
/**
* Represents the declaration of a data stream.
*
* StreamInfo is the primary metadata container for LSL streams. It stores:
* - Core properties: name, type, channel count, sampling rate, data format
* - Unique identifiers: source_id, uid, session_id
* - Host information: hostname, version, creation time
* - Extended metadata: channel labels, units, types via XML description
*
* @example
* ```typescript
* // Create a simple EEG stream
* const info = new StreamInfo('MyEEGStream', 'EEG', 8, 250, 'float32');
*
* // Add channel labels
* info.setChannelLabels(['Fp1', 'Fp2', 'F3', 'F4', 'C3', 'C4', 'P3', 'P4']);
* ```
*
* @class
*/
export class StreamInfo {
/** Pointer to the underlying LSL streaminfo C object */
obj;
/**
* Creates a new StreamInfo object.
*
* @param {string} name - Human-readable name of the stream (e.g., 'BioSemi')
* @param {string} type - Content type of the stream (e.g., 'EEG', 'Markers', 'Audio')
* @param {number} channelCount - Number of channels in the stream
* @param {number} nominalSrate - Nominal sampling rate in Hz (0 for irregular)
* @param {number|string} channelFormat - Data format ('float32', 'string', etc. or constant)
* @param {string|null} sourceId - Unique source identifier (auto-generated if not provided)
* @param {any} handle - Internal: existing C object handle for wrapping
*
* @throws {Error} If channel format is unknown or stream creation fails
*/
constructor(name = 'untitled', type = '', channelCount = 1, nominalSrate = IRREGULAR_RATE, channelFormat = cf_float32, sourceId, handle) {
if (handle !== undefined) {
// Wrap an existing C streaminfo object (used internally by resolver)
this.obj = handle;
}
else {
// Create a new streaminfo object
// Convert string format specification to numeric constant
if (typeof channelFormat === 'string') {
channelFormat = string2fmt[channelFormat];
if (channelFormat === undefined) {
throw new Error(`Unknown channel format: ${channelFormat}`);
}
}
// Generate source_id if not provided
if (sourceId === null || sourceId === undefined) {
// Create a deterministic hash from stream parameters
// This ensures the same parameters always generate the same source_id
const hashInput = `${name}${type}${channelCount}${nominalSrate}${channelFormat}`;
let hash = 0;
// Simple hash algorithm: djb2 variant
for (let i = 0; i < hashInput.length; i++) {
const char = hashInput.charCodeAt(i);
hash = ((hash << 5) - hash) + char; // hash * 33 + char
hash = hash & hash; // Convert to 32-bit integer
}
sourceId = hash.toString();
console.log(`Generated source_id: '${sourceId}' for StreamInfo with name '${name}', type '${type}', ` +
`channel_count ${channelCount}, nominal_srate ${nominalSrate}, ` +
`and channel_format ${channelFormat}.`);
}
// Create the LSL streaminfo C object
this.obj = lsl_create_streaminfo(name, type, channelCount, nominalSrate, channelFormat, sourceId);
if (!this.obj) {
throw new Error('Could not create stream description object.');
}
// Register for automatic cleanup when this object is garbage collected
streamInfoRegistry.register(this, this.obj, this);
}
}
/**
* Manually destroy the stream info object and free resources.
* This is called automatically when the object is garbage collected.
*/
destroy() {
if (this.obj) {
try {
streamInfoRegistry.unregister(this);
lsl_destroy_streaminfo(this.obj);
}
catch (e) {
console.error('StreamInfo deletion triggered error:', e);
}
this.obj = null;
}
}
/**
* Get the stream name.
* @returns The name of the stream
*/
name() {
return lsl_get_name(this.obj);
}
/**
* Get the stream type.
* @returns The content type of the stream (e.g., 'EEG', 'Markers')
*/
type() {
return lsl_get_type(this.obj);
}
/**
* Get the number of channels.
* @returns The channel count of the stream
*/
channelCount() {
return lsl_get_channel_count(this.obj);
}
/**
* Get the nominal sampling rate.
* @returns The sampling rate in Hz (0 for irregular rate)
*/
nominalSrate() {
return lsl_get_nominal_srate(this.obj);
}
/**
* Get the channel format.
* @returns The numeric channel format constant
*/
channelFormat() {
return lsl_get_channel_format(this.obj);
}
/**
* Get the unique source identifier.
* @returns The source ID of the stream
*/
sourceId() {
return lsl_get_source_id(this.obj);
}
/* ============================================================================
* HOSTING INFORMATION
* These properties are assigned when the stream is bound to an outlet/inlet
* ============================================================================ */
/**
* Get the protocol version used by the stream.
* @returns {number} LSL protocol version number
*/
version() {
return lsl_get_version(this.obj);
}
/**
* Get the creation timestamp of the stream.
* @returns {number} LSL timestamp when the stream was created
*/
createdAt() {
return lsl_get_created_at(this.obj);
}
/**
* Get the unique identifier of the stream.
* This UID is generated when the stream is created and remains constant.
* @returns {string} Unique stream identifier
*/
uid() {
return lsl_get_uid(this.obj);
}
/**
* Get the session identifier.
* Changes when the host system restarts or LSL is reinitialized.
* @returns {string} Current session identifier
*/
sessionId() {
return lsl_get_session_id(this.obj);
}
/**
* Get the hostname of the machine hosting the stream.
* @returns {string} Hostname or IP address
*/
hostname() {
return lsl_get_hostname(this.obj);
}
/* ============================================================================
* DATA DESCRIPTION
* Extended metadata stored as XML
* ============================================================================ */
/**
* Get the XML description of the stream.
* This contains extended metadata like channel labels, units, etc.
* @returns {XMLElement} Root XML element for manipulation
*/
desc() {
return new XMLElement(lsl_get_desc(this.obj));
}
/**
* Get the complete stream metadata as an XML string.
* Useful for debugging or saving stream configuration.
* @returns {string} XML representation of all stream metadata
*/
asXml() {
return lsl_get_xml(this.obj) || '';
}
/* ============================================================================
* CHANNEL METADATA METHODS
* Convenience methods for managing channel properties
* ============================================================================ */
/**
* Get the labels for all channels.
* @returns {string[]|null} Array of channel labels or null if not set
* @example
* const labels = info.getChannelLabels(); // ['Fp1', 'Fp2', ...]
*/
getChannelLabels() {
return this._getChannelInfo('label');
}
/**
* Get the types for all channels.
* @returns {string[]|null} Array of channel types or null if not set
* @example
* const types = info.getChannelTypes(); // ['EEG', 'EEG', ...]
*/
getChannelTypes() {
return this._getChannelInfo('type');
}
/**
* Get the units for all channels.
* @returns {string[]|null} Array of channel units or null if not set
* @example
* const units = info.getChannelUnits(); // ['microvolts', 'microvolts', ...]
*/
getChannelUnits() {
return this._getChannelInfo('unit');
}
/**
* Generic helper to extract channel information from XML.
* @private
* @param {string} name - Property name to extract ('label', 'type', or 'unit')
* @returns {string[]|null} Array of values or null if not found
*/
_getChannelInfo(name) {
const desc = this.desc();
// Check if channels element exists
if (desc.child('channels').empty()) {
return null;
}
const chInfos = [];
const channels = desc.child('channels');
let ch = channels.child('channel');
// Iterate through all channel elements
while (!ch.empty()) {
const chInfo = ch.child(name).firstChild().value();
if (chInfo.length !== 0) {
chInfos.push(chInfo);
}
else {
chInfos.push(null);
}
ch = ch.nextSibling();
}
// Return null if no channel has this property
if (chInfos.every(info => info === null)) {
return null;
}
// Warn if channel count mismatch
if (chInfos.length !== this.channelCount()) {
console.warn(`The stream description contains ${chInfos.length} elements for ` +
`${this.channelCount()} channels.`);
}
return chInfos.filter(info => info !== null);
}
/**
* Set labels for all channels.
* @param {string[]} labels - Array of channel labels (must match channel count)
* @throws {Error} If array length doesn't match channel count
* @example
* info.setChannelLabels(['Fp1', 'Fp2', 'F3', 'F4']);
*/
setChannelLabels(labels) {
this._setChannelInfo(labels, 'label');
}
/**
* Set types for all channels.
* @param {string|string[]} types - Single type for all channels or array of types
* @example
* info.setChannelTypes('EEG'); // All channels are EEG
* info.setChannelTypes(['EEG', 'EEG', 'EOG', 'EOG']); // Mixed types
*/
setChannelTypes(types) {
const typeArray = typeof types === 'string'
? new Array(this.channelCount()).fill(types)
: types;
this._setChannelInfo(typeArray, 'type');
}
/**
* Set units for all channels.
* @param {string|number|(string|number)[]} units - Single unit or array of units
* @example
* info.setChannelUnits('microvolts'); // All channels in microvolts
* info.setChannelUnits(['microvolts', 'millivolts', 'celsius', 'percent']);
*/
setChannelUnits(units) {
let unitArray;
if (typeof units === 'string' || typeof units === 'number') {
unitArray = new Array(this.channelCount()).fill(typeof units === 'number' ? units.toString() : units);
}
else {
unitArray = units.map(unit => typeof unit === 'number' ? unit.toString() : unit);
}
this._setChannelInfo(unitArray, 'unit');
}
/**
* Generic helper to set channel information in XML.
* @private
* @param {string[]} chInfos - Array of values to set
* @param {string} name - Property name ('label', 'type', or 'unit')
* @throws {Error} If array length doesn't match channel count
*/
_setChannelInfo(chInfos, name) {
if (chInfos.length !== this.channelCount()) {
throw new Error(`The number of provided channel ${name} ${chInfos.length} ` +
`must match the number of channels ${this.channelCount()}.`);
}
// Ensure channels element exists
const channels = StreamInfo._addFirstNode(this.desc.bind(this), 'channels');
let ch = channels.child('channel');
// Set info for each channel
for (const chInfo of chInfos) {
// Create channel element if needed
ch = ch.empty() ? channels.appendChild('channel') : ch;
StreamInfo._setDescriptionNode(ch, { [name]: chInfo });
ch = ch.nextSibling();
}
// Remove any extra channel elements
StreamInfo._pruneDescriptionNode(ch, channels);
}
/**
* Helper to ensure an XML node exists, creating it if necessary.
* @private
*/
static _addFirstNode(desc, name) {
const node = desc().child(name);
return node.empty() ? desc().appendChild(name) : node;
}
/**
* Helper to remove excess XML nodes.
* @private
*/
static _pruneDescriptionNode(node, parent) {
while (!node.empty()) {
const nodeNext = node.nextSibling();
parent.removeChild(node);
node = nodeNext;
}
}
/**
* Helper to set values in XML nodes.
* @private
*/
static _setDescriptionNode(node, mapping) {
for (const [key, value] of Object.entries(mapping)) {
// Value is already a string since mapping is { [key: string]: string }
if (node.child(key).empty()) {
node.appendChildValue(key, value);
}
else {
node.child(key).firstChild().setValue(value);
}
}
}
/**
* Get the internal C object handle.
* @internal Used by StreamOutlet and StreamInlet
* @returns {any} Pointer to the C streaminfo object
*/
getHandle() {
return this.obj;
}
}
/**
* Represents an XML element in the stream description.
*
* XMLElement provides methods for navigating and manipulating the XML tree
* structure that stores extended stream metadata. This is used for channel
* descriptions, hardware settings, synchronization parameters, etc.
*
* @example
* ```typescript
* const desc = info.desc();
* const acq = desc.appendChild('acquisition');
* acq.appendChildValue('manufacturer', 'ACME');
* acq.appendChildValue('model', 'StreamMaster3000');
* ```
*
* @class
*/
export class XMLElement {
/** Pointer to the underlying LSL XML element */
e;
/**
* Creates an XMLElement wrapper.
* @internal Created internally by StreamInfo methods
* @param {any} handle - C XML element pointer
*/
constructor(handle) {
this.e = handle;
}
/* ============================================================================
* TREE NAVIGATION
* Methods for traversing the XML tree structure
* ============================================================================ */
firstChild() {
return new XMLElement(lsl_first_child(this.e));
}
lastChild() {
return new XMLElement(lsl_last_child(this.e));
}
child(name) {
return new XMLElement(lsl_child(this.e, name));
}
nextSibling(name) {
if (name === undefined) {
return new XMLElement(lsl_next_sibling(this.e));
}
else {
return new XMLElement(lsl_next_sibling_n(this.e, name));
}
}
previousSibling(name) {
if (name === undefined) {
return new XMLElement(lsl_previous_sibling(this.e));
}
else {
return new XMLElement(lsl_previous_sibling_n(this.e, name));
}
}
parent() {
return new XMLElement(lsl_parent(this.e));
}
/* ============================================================================
* CONTENT QUERIES
* Methods for inspecting element content
* ============================================================================ */
empty() {
return Boolean(lsl_empty(this.e));
}
isText() {
return Boolean(lsl_is_text(this.e));
}
name() {
return lsl_name(this.e) || '';
}
value() {
return lsl_value(this.e) || '';
}
childValue(name) {
if (name === undefined) {
return lsl_child_value(this.e) || '';
}
else {
return lsl_child_value_n(this.e, name) || '';
}
}
/* ============================================================================
* MODIFICATION
* Methods for modifying the XML tree
* ============================================================================ */
appendChildValue(name, value) {
return new XMLElement(lsl_append_child_value(this.e, name, value));
}
prependChildValue(name, value) {
return new XMLElement(lsl_prepend_child_value(this.e, name, value));
}
setChildValue(name, value) {
lsl_set_child_value(this.e, name, value);
return this;
}
setName(name) {
return Boolean(lsl_set_name(this.e, name));
}
setValue(value) {
return Boolean(lsl_set_value(this.e, value));
}
appendChild(name) {
return new XMLElement(lsl_append_child(this.e, name));
}
prependChild(name) {
return new XMLElement(lsl_prepend_child(this.e, name));
}
appendCopy(elem) {
return new XMLElement(lsl_append_copy(this.e, elem.e));
}
prependCopy(elem) {
return new XMLElement(lsl_prepend_copy(this.e, elem.e));
}
removeChild(rhs) {
if (rhs instanceof XMLElement) {
lsl_remove_child(this.e, rhs.e);
}
else {
lsl_remove_child_n(this.e, rhs);
}
}
}
//# sourceMappingURL=streamInfo.js.map