Streams¶
A Stream wraps a Source plus a fixed-memory ring buffer. It owns one daemon acquisition thread, exposes one window-getter for predict code, and one decimated display getter for widgets.
The data shape contract¶
There are two coordinate systems, and they're easy to mix up:
| Where | Shape | Why |
|---|---|---|
Source read() returns |
(n_samples, n_channels) |
sample-major matches LSL, BrainFlow, BDI, BLE - every transport in the wild |
| Recording (Zarr) stores | (n_samples, n_channels) |
append-friendly: each chunk extends the time axis |
Stream.get_window() returns |
(n_channels, n_samples) |
channels-first - what feature extractors and ML models expect |
extract() receives |
dict[str, np.ndarray] channels-first |
matches get_window |
The transpose happens at one edge, in Stream.get_window(). New source adapters should keep the upstream sample-major orientation so recording and replay stay consistent.
Construction¶
namekeys the stream intoctx.streams[name].sourceis anything implementing theSourceprotocol (connect,read,disconnect).window_msis the duration ofget_window()'s slice. There's no upper bound; values like 30 s are intentional for slow-moving signals.buffer_msdefaults to 10 s. The ring buffer stores this much past data so the predict thread always has a window to slice andSignalViewercan render the recent history.
Reading the buffer¶
get_window() - for prediction¶
data, ts = stream.get_window()
# data.shape == (n_channels, n_samples)
# ts.shape == (n_samples,) # pylsl.local_clock() values
Returns the most recent window_ms of data, channels-first. ts[-1] is the timestamp of the newest sample - pass this into stateful models (e.g. model.step(emg, last_ts=ts[-1])) so they can detect stale ticks (predict thread firing faster than acquisition).
get_display(n_pixels) - for widgets¶
Returns an on-demand full-history M4 snapshot for compatibility. New widgets should request a bounded tail with get_raw_snapshot_stable(duration_s) and decimate it to their actual plot width, as SignalViewer does.
get_raw_snapshot() - for diagnostics¶
ts, data = stream.get_raw_snapshot()
# data.shape == (capacity, n_channels)
# ts.shape == (capacity,)
An on-demand contiguous copy of the full ring-buffer contents in their native orientation. Most user code should prefer get_window, or get_raw_snapshot_stable(duration_s) when a stable recent tail is required. RawSignalViewer uses the latter so its work is bounded by the visible duration.
Why dvg-ringbuffer¶
The ring buffer (dvg-ringbuffer) keeps memory bounded while acquisition runs indefinitely. That matters for two reasons:
- Constant acquisition cost. Appending a chunk never unwraps or copies the accumulated history.
- Bounded consumer reads.
get_window()copies only the prediction window andSignalVieweronly its visible tail, even when the ring holds much more history.
A threading.Lock guards reads and writes; overhead is ~1–5 microseconds per access - negligible compared to the actual work each thread does.
Lifecycle¶
You don't usually call start() / stop() directly:
app = App("Demo")
app.streams(Stream("emg", source=LSLSource("EMG"), window_ms=1000))
app.run() # starts every stream, runs the GUI, stops every stream on exit
For dynamic device swaps:
reconnect stops the acquisition thread, swaps the source, and restarts cleanly. The ring buffer is preserved across the swap so SignalViewer doesn't blank.
Channel naming¶
Sources may auto-discover channel names; if so, they appear in StreamInfo.channel_names:
info = stream.info # StreamInfo
info.n_channels # 64
info.fs # 2048.0
info.channel_names # ["EMG_01", "EMG_02", ...] or None
SignalViewer uses these names in its channel toggle list. If a source returns None, names default to "ch_0", "ch_1", …
Reading the waveform, not the envelope¶
SignalViewer MinMax-decimates: it keeps each bucket's minimum and maximum, so peak
height and burst timing are exact at any reduction — what coarsens is shape within a
bucket. At 2 kHz over a 5 s window on a 600 px plot that bucket is about 5 ms, which is
also roughly one pixel, so the trace is as faithful as the display can be.
Reading a feature shorter than that — a single motor-unit action potential — needs decimation off, not turned up. Detail tops out at a few points per pixel by design, because its ceiling is what stops a wide rig from stalling the frame rate. The 1:1 toggle beside it removes the reduction entirely:
| Want | Use |
|---|---|
| Amplitude, bursts, envelope | Leave it alone — MinMax is exact for these |
| Waveform shape below ~5 ms | 1:1, or shorten the window until the footer reads (raw) |
| Every sample, always, no toggle | RawSignalViewer |
The footer says which you are looking at: MinMax 10,000→1,802 pts/ch against
10,000 pts/ch (raw). Shortening the window reaches (raw) on its own, because
decimation stops once the window fits the point budget — at 600 px and full Detail that
is a 0.9 s window at 2 kHz.
1:1 refuses, and turns itself back off, above 250,000 points per frame across the drawn channels; 64 channels of 2 kHz over 10 s is 1.3 M points a frame for detail no display can resolve. Hover the toggle for the count at your current settings.
Common mistakes¶
See also: full Troubleshooting index, organised by symptom across every subsystem.
- Confusing window vs. buffer.
window_msis whatget_windowreturns;buffer_msis how much history the buffer holds. The latter only matters if you want to look back further than a window (e.g. for a 30 s signal viewer with a 1 s prediction window). - Forgetting the transpose. If you sub-class a Source and accidentally return
(n_channels, n_samples), the recording layer will write a Zarr array shaped wrong and replay won't match. Stay sample-major in the source. - Computing on the display path.
get_displayis decimated. For features, always useget_window.