(Note: This information is only applicable to the current development branch of the GUI)
Data in the GUI is divided into three main types:
- Continuous data: Analog data sampled at a continuous rate. Always in float32 format, which can represent different units
- Spike data: A snippet of continuous data representing a single spike. Same format and scaling as its source.
- Event: A discrete piece of data. Can be:
- A single numeric value (signed or unsigned integers of 8, 16, 32 or 64 bit or 32 or 64bit floats), referred as binary data
- An array of any of the aforementioned numeric values
- A text string
- A TTL event. This is a special type of event that represents a value change on a bit of a fixed-size binary array. Usually each bit is associated with an independent digital input on acquisition hardware.
All data is organized in channels. A channel is a strict definition of the data associated to it, so different kinds of data need to be sent on different channels. For example, a String event needs one channel, while an event with 3 floats representing positional data will need another. There can be different channels with the same definition, just to differentiate their sources. A classic example are the different continuous data channels that an acquisition system provides.
Channel definitions are stored in arrays present in the GenericProcessor class and can be accessed via simple get*Channel() methods. There are three sets of array and accessor methods, one for each main data type (continuous, spikes and events).
As such, any data present on the buffers of a processor can be tracked to a channel with its definition. For continuous data, that is always present on any call to the process() method, a simple indexing is used: The index of the channel definition in the array is the same as the index of the data in the AudioBuffer. Spike and events, which are discrete and might not be always present, are always defined by a triplet of 16bit integers representing their source processor ID and source subprocessor index of the node that created them and a unique index inside that source node. The getSpikeChannel() and getEventChannel() methods accept those indexes to locate the appropriate channel object.
All data in the GUI is timestamped. The timestamps are represented in in64 format, representing the sample count from the start of acquisition, which can be converted to seconds by dividing into the corresponding sample rate. In the case of continuous data, the timestamp corresponding to the first sample of each processing block can be obtained with the getTimestamp() method. For spikes and events, the timestamp is embedded into the data structure itself.
All channel objects have the following information:
- A human readable name
- A human readable description
A machine-readable point-separated identifier to identify the data being sent. E.g.: “external.network.rawData“. Right now I’ve pretty much made them up. At some point it would be good to try to create some kind of formal hierarchy, so plugins can try to use other user-created data types with ease.
- The sample rate its source acquires data, in case of continuous data, or to which the data timestamps are referred to, in case of spikes and events.
- Information about its source processor
The specific fields each channel has are:
- The type of data channel (HEADSTAGE, AUX, or ADC)
- A string field with the units the data represents
- A bitVolts field representing the conversion factor from float32 format to uint16 format.
- An automatically-generated string with a historic of which processors this data has been through
- Number of continuous channels this spike gets its data from
- Information fields about said continuous channels
- Number of samples a spike pertaining to this channel will have. This is divided between pre-peak and post-peak samples.
- Required number of bytes to store the spike data.
- Type of the data contained in each event (TEXT, TTL or any of the binary data)
- Number of virtual channels. This field, which has a special meaning for TTL events, allows a processor to send events from different origins inside it but sharing its definition thorough the same channel.
- For TTL events, it means the number of bits a TTL word will have
- For other events, it can be used to differentiate origins
- Length of the data
- For TTL events, setting this field is ignored. Reading it returns the number of bytes needed to hold the required number of TTL bits
- For TEXT events, the number of characters, not counting any kind of termination
- For other binary formats, the number of elements, independent of their size, that the event will hold
- Size of the data. A read-only field that returns the needed amount of bytes to store the event data.
Since the available data types may not always be enough for all applications, they can be extended using metadata fields. A metadata field can contain an array of integers (8, 16, 32 or 64 bit, signed or unsigned), floats (32 or 64 bit) or characters of any length. Like any data piece on the GUI, a metadata field is composed of a definition and the metadata value. A metadata definition contains, similarly to a channel definition:
- The data type
- The length of the data
- A human-readable name
- A human-readable description
- A machine-readable identifier
A metadata field can be attached to a channel definition when it is created to define extra information about the channel itself. In this case, both the metadata definition and value are statically tied to the channel and sent through all the chain. Metadata fields of this type are added to a channel via the addMetaData() method.
A metadata field can also be part of events and spikes themselves, indicating extra information about each different occurrence. In this case, the metadata definition is added to the channel definition, while the value is added to each event or spike individually. Metadata fields of this type are added to a channel via the addEventMetaData() method while the values will be added on event or spike creation. It is important to note that in this case, the metadata field becomes part of the data the channel is defining and, as such, every occurrence of the events or spikes associated with that channel must include values for all metadata defined.
More than one metadata field can be attached to either a channel or its events, so any kind of data structure can be created and sent down the chain in both configuration time and runtime.
Special arrays and pointers exist to hold metadata fields. They should almost never be used with normal pointers, except when adding them to one of the specialized arrays.
Configuration objects are very similar in nature to channel info objects, as they get created when a processor updates its information and are propagated down the signal chain. Unlike normal channels, though, they are not associated with any runtime data, only providing configuration-time information. They can be used to inform other processors of configuration settings that can be of interests.
A configuration object holds no data on itself. Instead, it is an empty container to which metadata fields can be attached to create any necessary data structure.
A processor must only provide very little information for continuous and event channels to be automatically created with the basic needed information.
For continuous data channels, which can be automatically created on source nodes, a processor must only override the getNumDefaultDataOutputs() method, returning the appropriate number of channels. They will be named depending on the type of continuous data they hold.
For events, a processor can just override the getDefaultEventInfo() method, filling an array with a simple struct, DefaultEventInfo, that contains the basic characteristics of an event channel, like type, length or number of virtual channels. A name, description and identifier can be optionally filled, otherwise the channels will be automatically named.
In both cases, if a plugin developer needs more control over the channel creation or wants to add metadata fields, he can instead override the createDataChannels() or createEventChannels() methods to manually create the channel objects and add them to dataChannelArray or eventChannelArray.
For spike channels, given their complexity, they must be manually created by overriding the createSpikeChannels() method, instantiating the objects and adding them to spikeChannelArray. The same is the case for configuration objects, for which the createConfigurationObjects() method is used.
All channel arrays can also be manipulated inside the updateSettings() method, so it is entirely possible to create the channels on that method. For maintainability sake, though, it is recommended to create the channels inside their own methods unless they need to be created after other update operations or otherwise necessary. While the arrays are accessible outside the update methods it is not advised to change them outside these methods. Any change made that way will not carry down the signal chain, nor any channel created outside these methods is guaranteed to be accessible through the get*Channel() methods.
The call order when a processor updates is:
- Create continuous data channels
- If Source node: call createDataChannels()
- Else, copy the data channel array from the preceding processor
- Call createEventChannels()
- Call createSpikeChannels()
- Call createConfigurationObjects()
- Call updateSettings()
Events and spikes
All events and spikes, referred usually as “events”, as they share many similarities, are created through dedicated classes that handle all the internals. Objects of those classes contain methods to access the relevant information, like virtual channel number the event was sent to, TTL state or TEXT string. The existing classes are:
- TTLEvent, for TTL events
- TextEvent, for TEXT events
- BinaryEvent, for events which hold arrays of binary data
- SpikeEvent, for spikes
Creating event objects
An event or spike cannot be instantiated manually by the use of the new operator. Instead, each event class has a specialized static factory method that takes all the necessary data to create an event as well as its channel definition, so it can check that no mismatch exists. Once created, an event or spike cannot be modified. An example of event creation could be:
TextEventPtr textEvent = TextEvent::createTextEvent(textEventChannelPointer, timestamp, "Event Text");
Note that special pointer types have been created for each class to ensure that no memory leaks exist. Those are typedefs to Juce’s ScopedPointers templated to the right classes.
If an event had metadata fields associated to it, they must be included in the factory method parameters using a MetaDataValueArray.
SpikeEvent objects are similar but need also a copy of the portion of continuous data that conform the spike. To do that a special buffer class, SpikeEvent::SpikeBuffer, exists. Data must be copied into the buffer using its set() methods and then the buffer will be passed into the SpikeEvent::createSpikeEvent() method. Once used to create a spike event object, the buffer will become invalid and must me reinitialized if it is to be reused for a new spike.
Once created, an event or spike can be added to the data buffers by using the addEvent() or addSpike() methods. Those methods need as parameters the channel definition, the event itself and an index indicating which sample in the current buffer triggered the event creation, if any. This last field can be zero if the event is not related to any continuous data. By calling this methods the event gets serialized into a simple binary stream and sent to the internal event buffers to be sent through the signal chain.
Reacting to events
Any processor can receive and react to events send earlier in the chain. To be able to do so,, a processor must first call the checkForEvents() method inside its process() method. An optional boolean parameter will tell if the processor wants to also check for spikes. Once called, it will in turn make multiple calls to the virtual handleEvent() and handleSpike() methods, which need to be overridden by the processor, once for each event or spike.
The handleEvent() and handleSpike() methods contain, as part of their arguments, a const reference to a MidiMessage which contains the serialized event as well as a const pointer to the matching channel definition. Event classes feature a static method to deserialize those messages into objects, similar to the create methods. In the same way, they need a pointer to the channel definition so it can know how to interpret the serialized data. An example would be:
TextEventPtr textEvent = TextEvent::deserializeFromMessage(midiMessage, channelPointer);
To identify which type of event a serialized message has, as well as other information that could be useful to know before deserializing the message, a series of static methods exist to extract those snippets of information directly from the message. Those are:
Common to both events and spikes
- EventBase::getBaseType() to identify if a message holds an event (PROCESSOR_EVENT) or a spike (SPIKE)
- EventBase::getSourceID() to extract the node id of the message source. (part of the triplet of values needed to uniquely identify a channel)
- EventBase::getSubProcessorIdx() to extract the subprocessor index of the message source. (part of the triplet of values needed to uniquely identify a channel)
- EventBase::getSourceIndex() to extract the message’s channel unique index in its source. (part of the triplet of values needed to uniquely identify a channel)
- EventBase::getTimestamp() to extract the timestamp of this message
Specific to events
- Event::getEventType() to identify the type of event (TTL, TEXT or any of the binary ones)