"""Real-time continuous M/EEG display with epoch/trigger markers.
Shows a scrolling multi-channel raw signal overlaid with event markers:
* Solid vertical line at each trigger (t = 0 of the epoch)
* Semi-transparent shaded region spanning [tmin, tmax] around each trigger
* Dashed boundary lines at the epoch edges (tmin, tmax)
tmin / tmax are adjustable live in the sidebar. Different event codes
are assigned distinct colours via the ``event_id`` mapping.
Classes
-------
EpochPlot
Scrolling raw viewer with epoch / trigger overlays.
"""
from __future__ import annotations
import datetime
from collections import deque
from pathlib import Path
import numpy as np
import pyqtgraph as pg
import pyqtgraph.exporters
from PyQt6.QtCore import QEvent, QObject, Qt, QTimer
from PyQt6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QScrollArea,
QScrollBar,
QVBoxLayout,
QWidget,
)
# ---------------------------------------------------------------------------
# Colour palettes
# ---------------------------------------------------------------------------
_TRACE_COLORS = [
"#4fc3f7", "#ef9a9a", "#a5d6a7", "#fff176", "#ffab91",
"#ce93d8", "#80cbc4", "#ffcc80", "#80deea", "#b39ddb",
"#f48fb1", "#c5e1a5", "#ffd54f", "#81d4fa", "#dce775",
"#ff8a65", "#90caf9", "#e6ee9c", "#bcaaa4", "#ffe082",
]
# Trigger colours: red, green, cyan, yellow, magenta, orange
_EVENT_COLORS = [
"#69f0ae", "#40c4ff", "#ffff00", "#ff9e80", "#ea80fc", "#80cbc4",
]
_TIME_WINDOW_OPTIONS = [2, 5, 10, 20]
_QSS = """
QMainWindow, QWidget {
background-color: #1a1a2e;
color: #e0e0e0;
font-family: "Segoe UI", sans-serif;
}
QPushButton {
background-color: #16213e;
color: #d0d0e8;
border: 1px solid #0f3460;
border-radius: 5px;
padding: 5px 10px;
font-size: 12px;
}
QPushButton:hover { background-color: #0f3460; }
QPushButton:pressed { background-color: #533483; }
QPushButton:checked {
background-color: #533483;
border-color: #a882dd;
color: #ffffff;
}
QComboBox {
background-color: #16213e;
color: #d0d0e8;
border: 1px solid #0f3460;
border-radius: 4px;
padding: 3px 6px;
}
QDoubleSpinBox, QSpinBox {
background-color: #16213e;
color: #d0d0e8;
border: 1px solid #0f3460;
border-radius: 4px;
padding: 2px 4px;
}
QGroupBox {
border: 1px solid #2a2a4a;
border-radius: 6px;
margin-top: 10px;
padding-top: 6px;
font-weight: bold;
font-size: 11px;
color: #8888aa;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 8px;
padding: 0 4px;
}
QLabel { color: #b0b0c8; font-size: 11px; }
QCheckBox { color: #b0b0c8; font-size: 11px; }
QScrollArea { border: none; }
QStatusBar { background-color: #0d0d1a; color: #606080; font-size: 10px; }
QScrollBar:vertical {
background-color: #0d0d1a;
width: 14px;
border: none;
margin: 0px;
}
QScrollBar::handle:vertical {
background-color: #2a2a4a;
border-radius: 4px;
min-height: 24px;
}
QScrollBar::handle:vertical:hover { background-color: #404060; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background-color: #0d0d1a;
}
"""
# ---------------------------------------------------------------------------
# Wheel-event filter (same as RawPlot)
# ---------------------------------------------------------------------------
class _WheelFilter(QObject):
def __init__(self, callback, parent=None):
super().__init__(parent)
self._cb = callback
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Wheel:
self._cb(1 if event.angleDelta().y() > 0 else -1)
return True
return False
# ---------------------------------------------------------------------------
# EpochPlot
# ---------------------------------------------------------------------------
[docs]
class EpochPlot(QMainWindow):
"""Real-time scrolling M/EEG viewer with epoch / trigger overlays.
Displays all channels stacked vertically (identical layout to
:class:`~mne_rt.viz.RawPlot`) with coloured event markers overlaid on
the signal. For each trigger event a solid vertical line marks t = 0,
a semi-transparent shaded band spans the epoch window [tmin, tmax], and
dashed boundary lines sit at the epoch edges.
Parameters
----------
ch_names : list of str
Channel names. One row per channel.
sfreq : float
Sampling frequency in Hz.
tmin : float, default -0.1
Epoch start in seconds relative to each trigger.
tmax : float, default 0.5
Epoch end in seconds relative to each trigger.
n_shown : int, default 20
Number of channels visible simultaneously.
time_window : float, default 10.0
Visible time range in seconds at startup.
scale_uv : float, default 100.0
Amplitude scale in µV (half of per-channel row height).
event_id : dict[str, int] | None, default None
Maps condition names to integer trigger codes. Each code gets a
distinct colour; unmapped codes use the first colour.
info : mne.Info | None, default None
If provided, used for channel-type detection and right-click sensor
position.
verbose : bool | str | None, default None
See Also
--------
mne_rt.viz.RawPlot : Continuous raw viewer without epoch overlays.
mne_rt.RTEpochs : Event-triggered epoch accumulator.
Notes
-----
Feed data with :meth:`push` (shape ``(n_ch, n_times)``) and trigger
events with :meth:`push_trigger`. Both calls are safe to make from
an acquisition thread.
.. versionadded:: 1.1.0
"""
[docs]
def __init__(
self,
ch_names: list[str],
sfreq: float,
tmin: float = -0.1,
tmax: float = 0.5,
n_shown: int = 20,
time_window: float = 10.0,
scale_uv: float = 100.0,
event_id: dict[str, int] | None = None,
info=None,
verbose=None,
) -> None:
from mne_rt._logging import set_log_level
set_log_level(verbose)
super().__init__()
self._ch_names = list(ch_names)
self._n_ch = len(ch_names)
self._sfreq = float(sfreq)
self._tmin = float(tmin)
self._tmax = float(tmax)
self._n_shown = min(int(n_shown), self._n_ch)
self._time_window = float(time_window)
self._scale = float(scale_uv) * 1e-6
self._event_id = dict(event_id) if event_id else {}
self._info = info
self._page_start = 0
self._paused = False
# Stream state
self._total_pushed: int = 0
n_pts = max(int(sfreq * time_window), 30)
self._time_axis = np.linspace(0.0, time_window, n_pts)
self._buf = np.zeros((self._n_ch, n_pts))
# Trigger history: (abs_sample_idx, event_code)
self._triggers: deque[tuple[int, int]] = deque(maxlen=500)
# Overlay items currently on the plot (cleared each redraw)
self._epoch_overlay_items: list = []
# Thread-safe pending queue: ('data', ndarray) | ('trigger', int)
# push()/push_trigger() enqueue here (any thread); _process_pending()
# drains it in the main Qt thread at 30 Hz via a QTimer.
self._pending: deque = deque()
# Build colour map: event_code → colour string
self._code_colors: dict[int, str] = {}
for i, (name, code) in enumerate(self._event_id.items()):
self._code_colors[code] = _EVENT_COLORS[i % len(_EVENT_COLORS)]
self._default_color = _EVENT_COLORS[0]
# Per-channel colours
self._ch_colors = [
_TRACE_COLORS[i % len(_TRACE_COLORS)] for i in range(self._n_ch)
]
pg.setConfigOptions(antialias=True, foreground="#c0c0d8", background="#0d0d1a")
self._build_ui()
self.setWindowTitle("MNE-RT — Epoch Viewer")
self.resize(1500, 720)
# 30 Hz render timer — processes queued data in the main Qt thread.
self._render_timer = QTimer(self)
self._render_timer.setInterval(33)
self._render_timer.timeout.connect(self._process_pending)
self._render_timer.start()
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self) -> None:
self.setStyleSheet(_QSS)
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(8, 8, 4, 8)
root.setSpacing(0)
root.addWidget(self._build_plot_widget(), stretch=5)
self._ch_scroll = QScrollBar(Qt.Orientation.Vertical)
self._ch_scroll.setRange(0, max(0, self._n_ch - self._n_shown))
self._ch_scroll.setPageStep(self._n_shown)
self._ch_scroll.setSingleStep(1)
self._ch_scroll.setFixedWidth(14)
self._ch_scroll.valueChanged.connect(
lambda v: self._set_page_start(v, source="scrollbar")
)
root.addWidget(self._ch_scroll)
root.addSpacing(4)
root.addWidget(self._build_control_panel(), stretch=0)
self._status = self.statusBar()
self._status.showMessage("Waiting for data …")
def _build_plot_widget(self) -> pg.GraphicsLayoutWidget:
glw = pg.GraphicsLayoutWidget()
glw.setBackground("#0d0d1a")
self._pi = glw.addPlot(row=0, col=0)
self._pi.setMouseEnabled(x=False, y=False)
vb = self._pi.getViewBox()
vb.setMouseEnabled(x=False, y=False)
vb.setMenuEnabled(False)
self._pi.showGrid(x=True, y=False, alpha=0.25)
for ax_name in ("left", "bottom"):
ax = self._pi.getAxis(ax_name)
ax.setPen(pg.mkPen("#303050"))
ax.setTextPen(pg.mkPen("#9090aa"))
self._pi.getAxis("left").setWidth(80)
self._pi.setLabel("bottom", "Time", units="s", color="#9090aa")
self._pi.setXRange(0.0, self._time_window, padding=0.01)
self._pi.setYRange(-0.5, self._n_shown - 0.5, padding=0)
self._curves: list[pg.PlotCurveItem] = [
pg.PlotCurveItem(pen=pg.mkPen(color=_TRACE_COLORS[0], width=1))
for _ in range(self._n_shown)
]
for c in self._curves:
self._pi.addItem(c)
sep_pen = pg.mkPen(color=(70, 70, 110, 55), width=1, style=Qt.PenStyle.DotLine)
for i in range(self._n_shown):
self._pi.addItem(pg.InfiniteLine(pos=i, angle=0, pen=sep_pen))
self._update_tick_labels()
self._wheel_filter = _WheelFilter(self._on_plot_wheel, self)
glw.viewport().installEventFilter(self._wheel_filter)
glw.scene().sigMouseClicked.connect(self._on_scene_clicked)
self._glw = glw
return glw
def _build_control_panel(self) -> QScrollArea:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFixedWidth(230)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setSpacing(8)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self._grp_playback())
layout.addWidget(self._grp_amplitude())
layout.addWidget(self._grp_display())
layout.addWidget(self._grp_epoch())
layout.addWidget(self._grp_events())
layout.addStretch()
scroll.setWidget(panel)
return scroll
# ── sidebar groups ─────────────────────────────────────────────────
def _grp_playback(self) -> QGroupBox:
grp = QGroupBox("Playback")
lay = QVBoxLayout(grp)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setCheckable(True)
self._btn_pause.clicked.connect(self._toggle_pause)
btn_clear = QPushButton("⟳ Clear")
btn_clear.clicked.connect(self._clear)
btn_shot = QPushButton("📷 Screenshot")
btn_shot.clicked.connect(self._screenshot)
for w in (self._btn_pause, btn_clear, btn_shot):
lay.addWidget(w)
return grp
def _grp_amplitude(self) -> QGroupBox:
grp = QGroupBox("Amplitude")
lay = QVBoxLayout(grp)
self._scale_lbl = QLabel(self._fmt_scale())
self._scale_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._scale_lbl.setStyleSheet("color:#7ec8e3; font-size:12px; font-weight:bold;")
row = QHBoxLayout()
btn_dn = QPushButton("÷2"); btn_up = QPushButton("×2")
for b in (btn_dn, btn_up):
b.setFixedSize(42, 26)
btn_dn.clicked.connect(self._scale_down)
btn_up.clicked.connect(self._scale_up)
row.addStretch(); row.addWidget(btn_dn); row.addWidget(btn_up); row.addStretch()
lay.addWidget(self._scale_lbl); lay.addLayout(row)
return grp
def _grp_display(self) -> QGroupBox:
grp = QGroupBox("Display")
lay = QVBoxLayout(grp)
row = QHBoxLayout()
row.addWidget(QLabel("Time window:"))
from PyQt6.QtWidgets import QComboBox
self._cmb_tw = QComboBox()
for secs in _TIME_WINDOW_OPTIONS:
self._cmb_tw.addItem(f"{secs} s", secs)
best = min(_TIME_WINDOW_OPTIONS, key=lambda s: abs(s - self._time_window))
self._cmb_tw.setCurrentIndex(_TIME_WINDOW_OPTIONS.index(best))
self._cmb_tw.currentIndexChanged.connect(self._change_time_window)
row.addWidget(self._cmb_tw); lay.addLayout(row)
chk_grid = QCheckBox("Show grid")
chk_grid.setChecked(True)
chk_grid.toggled.connect(
lambda on: self._pi.showGrid(x=on, y=False, alpha=0.25 if on else 0.0)
)
lay.addWidget(chk_grid)
return grp
def _grp_epoch(self) -> QGroupBox:
grp = QGroupBox("Epoch Window")
lay = QVBoxLayout(grp); lay.setSpacing(5)
tmin_row = QHBoxLayout()
tmin_row.addWidget(QLabel("tmin:"))
self._tmin_spin = QDoubleSpinBox()
self._tmin_spin.setRange(-5.0, 0.0)
self._tmin_spin.setValue(self._tmin)
self._tmin_spin.setSuffix(" s")
self._tmin_spin.setDecimals(2)
self._tmin_spin.setSingleStep(0.05)
tmin_row.addWidget(self._tmin_spin)
lay.addLayout(tmin_row)
tmax_row = QHBoxLayout()
tmax_row.addWidget(QLabel("tmax:"))
self._tmax_spin = QDoubleSpinBox()
self._tmax_spin.setRange(0.0, 5.0)
self._tmax_spin.setValue(self._tmax)
self._tmax_spin.setSuffix(" s")
self._tmax_spin.setDecimals(2)
self._tmax_spin.setSingleStep(0.05)
tmax_row.addWidget(self._tmax_spin)
lay.addLayout(tmax_row)
btn_apply = QPushButton("Apply")
btn_apply.setStyleSheet(
"background:#132744; color:#80d8ff; border:1px solid #2a6090;"
"border-radius:4px; padding:4px; font-size:11px;"
)
btn_apply.clicked.connect(self._apply_epoch_window)
lay.addWidget(btn_apply)
self._epoch_lbl = QLabel(f"Window: {self._tmin:.2f} → {self._tmax:.2f} s")
self._epoch_lbl.setStyleSheet("color:#80d8ff; font-size:10px;")
self._epoch_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay.addWidget(self._epoch_lbl)
chk_region = QCheckBox("Show epoch region")
chk_region.setChecked(True)
chk_region.toggled.connect(self._set_show_epoch_region)
lay.addWidget(chk_region)
self._show_epoch_region: bool = True
chk_bounds = QCheckBox("Show tmin/tmax lines")
chk_bounds.setChecked(True)
chk_bounds.toggled.connect(self._set_show_epoch_bounds)
lay.addWidget(chk_bounds)
self._show_epoch_bounds: bool = True
return grp
def _grp_events(self) -> QGroupBox:
grp = QGroupBox("Events")
lay = QVBoxLayout(grp); lay.setSpacing(4)
self._event_count_lbl = QLabel("No triggers received")
self._event_count_lbl.setStyleSheet("color:#505070; font-size:10px;")
self._event_count_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay.addWidget(self._event_count_lbl)
btn_clear_trigs = QPushButton("Clear triggers")
btn_clear_trigs.clicked.connect(self._clear_triggers)
lay.addWidget(btn_clear_trigs)
# Legend: one colour swatch per event code
if self._event_id:
lay.addWidget(QLabel("─── Legend ───"))
for name, code in self._event_id.items():
color = self._code_colors.get(code, self._default_color)
legend_row = QHBoxLayout()
swatch = QLabel("■")
swatch.setStyleSheet(f"color:{color}; font-size:14px;")
legend_row.addWidget(swatch)
legend_row.addWidget(QLabel(f"{name} (code {code})"))
legend_row.addStretch()
lay.addLayout(legend_row)
return grp
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _fmt_scale(self) -> str:
uv = self._scale * 1e6
if uv >= 1000: return f"{uv / 1000:.4g} mV / row"
if uv >= 1: return f"{uv:.4g} µV / row"
return f"{uv * 1000:.4g} nV / row"
def _update_tick_labels(self) -> None:
end = min(self._page_start + self._n_shown, self._n_ch)
n_actual = end - self._page_start
ticks = [
(n_actual - 1 - i, self._ch_names[self._page_start + i])
for i in range(n_actual)
]
self._pi.getAxis("left").setTicks([ticks, []])
def _set_page_start(self, new_start: int, source: str = "other") -> None:
new_start = max(0, min(new_start, max(0, self._n_ch - self._n_shown)))
if new_start == self._page_start:
return
self._page_start = new_start
self._update_tick_labels()
if source != "scrollbar":
self._ch_scroll.blockSignals(True)
self._ch_scroll.setValue(new_start)
self._ch_scroll.blockSignals(False)
self._redraw()
def _trigger_color(self, code: int) -> str:
return self._code_colors.get(code, self._default_color)
# ------------------------------------------------------------------
# Callbacks — scroll / wheel / click
# ------------------------------------------------------------------
def _on_plot_wheel(self, direction: int) -> None:
step = max(1, self._n_shown // 4)
self._set_page_start(self._page_start - direction * step, source="wheel")
def _on_scene_clicked(self, event) -> None:
if event.button() != Qt.MouseButton.RightButton:
return
pos = event.scenePos()
axis = self._pi.getAxis("left")
if not axis.sceneBoundingRect().contains(pos):
return
y_val = self._pi.getViewBox().mapSceneToView(pos).y()
end = min(self._page_start + self._n_shown, self._n_ch)
n_actual = end - self._page_start
vis_idx = int(round(n_actual - 1 - y_val))
if 0 <= vis_idx < n_actual:
ch_idx = self._page_start + vis_idx
self._show_channel_location(self._ch_names[ch_idx])
def _show_channel_location(self, ch_name: str) -> None:
if self._info is None:
self._status.showMessage(f"No Info — cannot show position for {ch_name}")
return
try:
import mne, matplotlib.pyplot as plt
fig = mne.viz.plot_sensors(
self._info, show_names=True,
title=f"Sensor position — {ch_name}", show=False,
)
for ax in fig.axes:
for txt in ax.texts:
if txt.get_text() == ch_name:
txt.set_color("#cc0000"); txt.set_fontsize(10)
txt.set_fontweight("bold")
plt.show(block=False)
except Exception as exc:
self._status.showMessage(f"Could not show sensor position: {exc}")
# ------------------------------------------------------------------
# Callbacks — playback / display
# ------------------------------------------------------------------
def _toggle_pause(self, checked: bool) -> None:
self._paused = checked
self._btn_pause.setText("▶ Resume" if checked else "⏸ Pause")
def _clear(self) -> None:
self._pending.clear()
self._buf[:] = 0.0
self._triggers.clear()
self._epoch_overlay_items.clear()
self._total_pushed = 0
self._redraw()
def _screenshot(self) -> None:
from PyQt6.QtWidgets import QFileDialog
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
default = str(Path.home() / f"epoch_plot_{ts}.png")
path, _ = QFileDialog.getSaveFileName(
self, "Save Screenshot", default, "PNG Image (*.png)"
)
if not path: return
exp = pg.exporters.ImageExporter(self._glw.scene())
exp.parameters()["width"] = 1920
exp.export(path)
def _scale_up(self) -> None:
self._scale *= 2.0
self._scale_lbl.setText(self._fmt_scale())
self._redraw()
def _scale_down(self) -> None:
self._scale /= 2.0
self._scale_lbl.setText(self._fmt_scale())
self._redraw()
def _change_time_window(self, idx: int) -> None:
secs = float(self._cmb_tw.itemData(idx))
self._time_window = secs
n_pts = max(int(self._sfreq * secs), 30)
self._time_axis = np.linspace(0.0, secs, n_pts)
self._buf = np.zeros((self._n_ch, n_pts))
# _total_pushed intentionally kept — triggers remain valid relative to stream
self._pi.setXRange(0.0, secs, padding=0.01)
def _apply_epoch_window(self) -> None:
self._tmin = self._tmin_spin.value()
self._tmax = self._tmax_spin.value()
self._epoch_lbl.setText(f"Window: {self._tmin:.2f} → {self._tmax:.2f} s")
self._redraw()
def _set_show_epoch_region(self, on: bool) -> None:
self._show_epoch_region = on
self._redraw()
def _set_show_epoch_bounds(self, on: bool) -> None:
self._show_epoch_bounds = on
self._redraw()
def _clear_triggers(self) -> None:
self._triggers.clear()
self._event_count_lbl.setText("No triggers received")
self._event_count_lbl.setStyleSheet("color:#505070; font-size:10px;")
self._redraw()
# ------------------------------------------------------------------
# Epoch overlay rendering
# ------------------------------------------------------------------
def _redraw_epoch_overlays(self) -> None:
for item in self._epoch_overlay_items:
self._pi.removeItem(item)
self._epoch_overlay_items.clear()
if not self._triggers:
return
buf_size = self._buf.shape[1]
buf_start = self._total_pushed - buf_size # absolute sample of leftmost buf column
dash_style = Qt.PenStyle.DashLine
for (trig_abs, code) in self._triggers:
# x coordinate of the trigger (t=0) in the current view
x0 = (trig_abs - buf_start) / self._sfreq
# Accept if the epoch window overlaps the visible range
x_lo = x0 + self._tmin
x_hi = x0 + self._tmax
if x_hi < 0 or x_lo > self._time_window:
continue
color = self._trigger_color(code)
# ── solid trigger line at t=0 ──────────────────────────────
trig_line = pg.InfiniteLine(
pos=x0, angle=90,
pen=pg.mkPen(color=color, width=2),
)
self._pi.addItem(trig_line)
self._epoch_overlay_items.append(trig_line)
# ── shaded epoch region ────────────────────────────────────
if self._show_epoch_region:
r, g, b = self._hex_to_rgb(color)
region = pg.LinearRegionItem(
values=(x_lo, x_hi),
brush=pg.mkBrush(r, g, b, 30),
movable=False,
pen=pg.mkPen(None), # no border line on the region itself
)
self._pi.addItem(region)
self._epoch_overlay_items.append(region)
# ── dashed epoch boundary lines ────────────────────────────
if self._show_epoch_bounds:
for x_bnd in (x_lo, x_hi):
if 0 <= x_bnd <= self._time_window:
bnd_line = pg.InfiniteLine(
pos=x_bnd, angle=90,
pen=pg.mkPen(color=color, width=1, style=dash_style),
)
self._pi.addItem(bnd_line)
self._epoch_overlay_items.append(bnd_line)
@staticmethod
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
h = hex_color.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
# ------------------------------------------------------------------
# Redraw
# ------------------------------------------------------------------
def _redraw(self) -> None:
end = min(self._page_start + self._n_shown, self._n_ch)
visible = list(range(self._page_start, end))
n_actual = len(visible)
gain = 1.0 / (self._scale + 1e-300)
for vis_idx, ch_idx in enumerate(visible):
raw = self._buf[ch_idx].copy()
color = self._ch_colors[ch_idx]
self._curves[vis_idx].setPen(pg.mkPen(color=color, width=1))
offset = float(n_actual - 1 - vis_idx)
self._curves[vis_idx].setData(self._time_axis, offset + raw * gain)
for vis_idx in range(n_actual, self._n_shown):
self._curves[vis_idx].setData([], [])
self._redraw_epoch_overlays()
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
[docs]
def push(self, data: np.ndarray) -> None:
"""Enqueue a raw data chunk for display.
Thread-safe: may be called from any thread. The data is written into
the circular buffer and rendered by the main-thread timer at ~30 Hz.
Parameters
----------
data : ndarray, shape (n_channels, n_samples)
New raw data chunk. No-op when paused.
"""
if self._paused:
return
self._pending.append(('data', data.copy()))
[docs]
def push_trigger(self, code: int = 1) -> None:
"""Enqueue a trigger event at the current stream position.
Thread-safe: may be called from any thread. The trigger is placed
after all data chunks already in the queue, so the sample index is
computed correctly in the main thread.
Parameters
----------
code : int, default 1
Integer event code matched against :attr:`event_id`.
"""
self._pending.append(('trigger', code))
def _process_pending(self) -> None:
"""Drain the pending queue and redraw — called in the main thread at 30 Hz."""
if not self._pending:
return
changed = False
while self._pending:
kind, payload = self._pending.popleft()
if kind == 'data':
n = payload.shape[1]
self._buf = np.roll(self._buf, -n, axis=1)
self._buf[:, -n:] = payload
self._total_pushed += n
changed = True
else: # trigger
self._triggers.append((self._total_pushed, payload))
n_t = len(self._triggers)
self._event_count_lbl.setText(
f"{n_t} trigger{'s' if n_t != 1 else ''} received"
)
self._event_count_lbl.setStyleSheet("color:#80d8ff; font-size:10px;")
changed = True
if changed:
end = min(self._page_start + self._n_shown, self._n_ch)
self._status.showMessage(
f"Streaming — ch {self._page_start + 1}–{end} of {self._n_ch}"
f" | triggers: {len(self._triggers)}"
)
self._redraw()
[docs]
def closeEvent(self, event) -> None:
self._render_timer.stop()
super().closeEvent(event)