The Physical computing Streaming Sensor Sonification Toolkit (PSSST) is designed to simplify the authoring of sonification of live sensor data for the purposes of understanding, testing, and debugging sensors. The toolkit is designed more generally to support authoring across different types of streaming data, but has prioritized features that will specifically be helpful when dealing with multiple sensor data streams.
The basic architecture of PSSST is strea based. Two different streams are needed to visualize data:
OutputStateChange
events which provide information about whether PSSST should do things like start, pause, or stop outputDatum
events to be outputedThese are merged together into [OutputStateChange
, Datum
] tuples and passed various parts of the PSSST architecture subscribe to this stream and act on it. More details on all of this below.
yarn install
yarn start
We start with a simple example of how to use PSSST. This example is a summary of what is done in DemoSimple.tsx. We break this up according to the two streams described above, along with sonification authoring.
DemoSimple
is a very simple demonstration app that allows the user to pause and play data from a fixed CSV file at a fixed rate (once every 500 ms)
OutputStateChange
] StreamDemoSimple
provides the user with an interface for playing and pausing data streaming, and this same interface is used to generate the stream of OutputStateChange
events. To do this, DemoSimple
must
OutputEngine
using OutputEngine.getInstance()
This method enforces that there is only ever one OutputEngine. The OutputEngine is the repository for all data outputDemoSimple
simply calls outputEngine.next(OutputSateChange.[State])
on the OutputEngine
instance. This in turn will ensure that anything subscribed to the stream of tuples being produced by OutputEngine
is updated.OutputEngine
is be started up, using outputEngine.next(OutputStateChange.Play)
To author a sonification, or any other display of information,DemoSimple
must specify a few key pieces of information:
DataSink
to the OutputEngine, by calling outputEngine.addSink("name")
on the OutputEngine
instance.DataSink
must be configured.
DataSink
] must have a stream of data to display. This is provided by calling outputEngine.setStream(stream$)
.DataHandler
class which knows how to handle and potentially output the data. For example, in DemoSimply
, we add a NoteHandler
to this sink, which will cause incoming data to be mapped into an audible range and then outputed as audible notes created using an oscillator. It is possible to add multilpe such handlers to a single sink.Datum
] StreamingDemoSimple
also needs to stream data to the OutputEngine
. In DemoSimple
we do this by creating a stream from the original array of data as follows:
let data$ = of(...data)
let timer$ = timer(0, 500).pipe(debug(SonificationLoggingLevel.DEBUG, 'point number'))
let source$ = zip(data$, timer$, (num, time) => new Datum(id, num)).pipe(
debug(SonificationLoggingLevel.DEBUG, 'point'),
)
Once a stream exists (whereever it comes from), it is a simple matter of asking the OutputEngine
to pipe it into a specific DataSink
as follows: OutputEngine.getInstance().setStream(id, source$)
.
The OutputEngine
class is designed to only ever have a single instance. This means that even as the user flips from one webpage to another there is still a way to keep outputs running (or turn them off, or change their volume). It also handles glabel needs like pausing the audio for all data sinks, generating a new data sink class with a unique ID as needed, adjusting overall volume (still TBD).
It's primary purpose, however, is to merge the incoming global stream of OutputStateChange
events with each DataSink
's individual stream of Datum
events and to keep track of all the sinks and their streams.
Data sinks are holders for a specific data sink. For the most part, our current implementation assumes each DataSink
class will handle only a single stream of timestamped streaming numbers (specifically, streames containing the Datum
class. As described above, streaming data can either be provided as an Rxjs Observable
or through a call to OutputEngine.pushPoint()
.
Data sinks contain a chain of Handler
classes which handle incoming data. Handlers are described more in depth below, but they may filter data, calculate an adjusted value for data, and/or cause data to be outputed. They can be thought of as dispatch handlers for data. The Data sink ensures that these handlers are chained together, so that if one handler receives a stream of data and filters it, the next handler will only see the filtered version of the data. A Data sink also has a name, and a unique ID, which it enforces is added to every incoming data point.
A Datum
is just a single item of a data stream. It knows which sink it is being handled by, and has a raw value, a timestamp, and an adjusted value. Some outputs may use the adjusted value instead of the raw value to decide how to render the Datum.
A Handler is an abstract class that is designed to handle streaming data. Handlers are chained together by the DataSink
class, and any handler can also have an DataOutput
class that displays the data the handler is streaming.
A DataHandler
should behave in the following ways
A handler may have one or more DatumOutput
classes which are used to render the Datum
. Each of these will be subscribed to the handler's output.
Some examples may help to clarify how Handler
classes work.
FilterRangeHandler
filters out data points outside its specified range. Right now, this class supports only a single range with no intelligence, but more sophisticated filters could be imagined. If a output is attached to the FilterRangeHandler
, it receive only the filtered data.ScaleHandler
takes in a conversion function and uses it to modify every Datum
it sees. We subclass it in NoteHandler
to parameterize it with a Mel Scale transformation.We haven't implemented all handlers. Some additional ones that we are thinking of adding:
The same output can be used by more than one handler. We currently include only outputs that sonify data with nonspeech audio, though we plan to add a speech output. They all inheret from Sonify
, which in turn inherets from DatumOutput
. We currently support the following
NoteSonify
which uses Datum.adjustedValue
as a frequency for an oscillator. It plays data continuously and only stops when the data stream ends on stop()
is called.NoiseSonify
, which inherets from SonifyFixedDuration
and creates white noise when a Datum
is passed to it.The base class for outputs processes the data stream and calls start()
, stop()
, and pause()
when there is a new OutputStateChange
event. It also calls output(datum)
when state is OutputStateChange.Playing
. Subclasses only need to implement these methods to function properly.
Statistics are used to calculate information about a stream, such as the maximum number seen so far, or a running average. They may be used by DatumOutput
or DataHandler
classes to make decisions about things (such as what range to filter over).
The base statistic class strips everything out of the stream except the number inside the Datum
object, so that stats calculations can just work on pure streams of numbers, to simplify things.
Currently the only implemented statistics are:
RunningAverage
, which averages the last n values as follows: bufferCount(this.buffer),
map((frames) => {
const total = frames.reduce((acc, curr) => {
acc += curr
return acc
}, 0)
return 1 / (total / frames.length)
}),
RangeEndExpander
which grows to always match either the largest, or smallest, data point seen so farIt uses the following code: reduce((acc, curr) => {
if (this.direction == GrowthDirection.Min) {
return curr >= acc ? acc : curr
} else {
return curr <= acc ? acc : curr
}
}),
However we have discussed other ideas for statistics such as an Extreme
(could keep track of outliers in the data stream)