plasma-app 2.2.4__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plasma_app-2.2.4 → plasma_app-2.3.0}/PKG-INFO +1 -1
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__init__.py +1 -1
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__main__.py +27 -1
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/integrated_panel.py +170 -31
- plasma_app-2.3.0/plasma/task_buzzer.py +97 -0
- plasma_app-2.3.0/plasma/task_sequence.py +130 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/PKG-INFO +1 -1
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/SOURCES.txt +2 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/LICENSE +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/README.md +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__pyinstaller/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__pyinstaller/hook-plasma.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/api.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/app_context.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/build_info.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/config.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/ctl.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/desktop_shortcut.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/bitalino.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/ble_scan.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/bleak_probe.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/bleak_reconnect_test.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/config.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/detect.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/device.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/__main__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/clocksync.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/options.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/pipeline.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/formats/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/gyro_bias.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/nus_sim.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/nus_stream.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/_common.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/clocksync.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/control.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/downloader.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/extractor.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/imu.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/sqc.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/tools.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/uuid_tools.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/viewer.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/quaternion.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/records.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/signal_quality.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/__init__.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/config.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/device.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/fake_stream.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/faults.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/obs.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/pupil_labs.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/qb2.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/shimmer.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/template.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/journal.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/lsl_recorder.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/lsl_session.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/lsl_util.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/plot_util.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/plugins.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.icns +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.ico +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.png +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.svg +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/status.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/xdf_writer.py +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/dependency_links.txt +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/entry_points.txt +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/requires.txt +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/top_level.txt +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/pyproject.toml +0 -0
- {plasma_app-2.2.4 → plasma_app-2.3.0}/setup.cfg +0 -0
|
@@ -6,6 +6,7 @@ import gradio as gr
|
|
|
6
6
|
from plasma import plugins
|
|
7
7
|
from plasma.app_context import app_context
|
|
8
8
|
from plasma.integrated_panel import IntegratedPanel
|
|
9
|
+
from plasma.task_buzzer import BUZZER_JS
|
|
9
10
|
from plasma.config import device_config
|
|
10
11
|
from plasma import api as plasma_api
|
|
11
12
|
|
|
@@ -99,6 +100,25 @@ function refresh() {
|
|
|
99
100
|
});
|
|
100
101
|
obs.observe(document.body, {childList: true, subtree: true});
|
|
101
102
|
window.addEventListener('pagehide', () => obs.disconnect(), {once: true});
|
|
103
|
+
""" + BUZZER_JS + """
|
|
104
|
+
// SPACE starts a ready task only while its controls are visible. Let
|
|
105
|
+
// inputs, buttons and other keyboard controls retain their native behavior.
|
|
106
|
+
const taskKey = (event) => {
|
|
107
|
+
if (event.code !== 'Space' || event.repeat || event.altKey ||
|
|
108
|
+
event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
109
|
+
if (event.target.closest('input, textarea, button, select, a, [role="tab"], [contenteditable="true"]')) return;
|
|
110
|
+
for (const control of document.querySelectorAll('#plasma-task-start, #plasma-focus-task-start')) {
|
|
111
|
+
const button = control.matches('button') ? control : control.querySelector('button');
|
|
112
|
+
if (button && !button.disabled && button.getClientRects().length) {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
unlockAudio();
|
|
115
|
+
button.click();
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener('keydown', taskKey);
|
|
121
|
+
window.addEventListener('pagehide', () => document.removeEventListener('keydown', taskKey), {once: true});
|
|
102
122
|
}
|
|
103
123
|
"""
|
|
104
124
|
|
|
@@ -107,11 +127,15 @@ def build_blocks(ip):
|
|
|
107
127
|
the given `IntegratedPanel`. Shared by `main()` and the screenshot-capture
|
|
108
128
|
tool (`scripts/capture_screenshots.py`) so both build the identical UI.
|
|
109
129
|
Does not launch the server."""
|
|
110
|
-
with gr.Blocks(title=app_context().app_name, theme=gr.themes.Ocean(), js=js_func
|
|
130
|
+
with gr.Blocks(title=app_context().app_name, theme=gr.themes.Ocean(), js=js_func,
|
|
131
|
+
css="#plasma-focus-memo {max-height: calc(100vh - 180px); overflow-y: auto;} "
|
|
132
|
+
"#plasma-buzzer-event {display:none;}") as app:
|
|
111
133
|
yams_title = None
|
|
112
134
|
with gr.Tabs() as top_tabs:
|
|
113
135
|
with gr.Tab("Session Dashboard"):
|
|
114
136
|
ip.interface()
|
|
137
|
+
with gr.Tab("Focus"):
|
|
138
|
+
ip.focus_interface()
|
|
115
139
|
with gr.Tab("Data Dashboard"):
|
|
116
140
|
ip.visualizer_interface()
|
|
117
141
|
# extra tabs contributed by enabled plugins (e.g. MSense SQC / IMU).
|
|
@@ -134,6 +158,8 @@ def build_blocks(ip):
|
|
|
134
158
|
# pl.interface()
|
|
135
159
|
|
|
136
160
|
_wire_plot_timer_gate(ip, top_tabs, yams_title)
|
|
161
|
+
ip.wire_session_views(top_tabs)
|
|
162
|
+
ip.wire_task_buzzer()
|
|
137
163
|
|
|
138
164
|
# stop any live MSense stream when the last browser tab goes away — a
|
|
139
165
|
# continuous stream with nobody watching just churns the BLE radio and
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import atexit
|
|
2
|
+
import csv
|
|
2
3
|
import gradio as gr
|
|
3
4
|
import struct
|
|
4
5
|
import os
|
|
@@ -7,6 +8,8 @@ import html as _html
|
|
|
7
8
|
import re
|
|
8
9
|
from plasma.lsl_session import encode_participant, SessionInfo
|
|
9
10
|
from plasma.journal import open_journal_outlet
|
|
11
|
+
from plasma.task_sequence import TaskSequence, read_tasks
|
|
12
|
+
from plasma.task_buzzer import BUZZER_CONTROLS
|
|
10
13
|
import logging, datetime, time
|
|
11
14
|
from logging import Logger
|
|
12
15
|
from plasma import plugins, __version__, build_info
|
|
@@ -64,16 +67,16 @@ def _fmt_elapsed(seconds):
|
|
|
64
67
|
|
|
65
68
|
_MEMO_CSS = (
|
|
66
69
|
"<style>"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
f"
|
|
75
|
-
f"
|
|
76
|
-
|
|
70
|
+
".plasma-memo .prose{max-width:none}"
|
|
71
|
+
".plasma-memo .m-wrap{font:13px/1.5 ui-monospace,Menlo,Consolas,monospace}"
|
|
72
|
+
".plasma-memo .m-hdr{font-weight:600;margin:1px 0 5px}"
|
|
73
|
+
".plasma-memo .m-row{margin:4px 0}"
|
|
74
|
+
".plasma-memo .m-name{font-weight:600}"
|
|
75
|
+
".plasma-memo .m-tag{opacity:.6;font-weight:400;margin-left:7px}"
|
|
76
|
+
".plasma-memo .m-sub{color:#555;padding-left:18px;font-size:12px}"
|
|
77
|
+
f".plasma-memo .m-sub.rec{{color:{_STATUS_HEX['guidance']}}}"
|
|
78
|
+
f".plasma-memo .m-sub.guidance{{color:{_STATUS_HEX['guidance']}}}"
|
|
79
|
+
".plasma-memo .m-dim{opacity:.55}"
|
|
77
80
|
"</style>"
|
|
78
81
|
)
|
|
79
82
|
|
|
@@ -185,6 +188,7 @@ class IntegratedPanel():
|
|
|
185
188
|
|
|
186
189
|
# session task-marker LSL stream (None if liblsl is unavailable)
|
|
187
190
|
self.journal_outlet = open_journal_outlet()
|
|
191
|
+
self.task_sequence = TaskSequence(self._sequence_marker)
|
|
188
192
|
|
|
189
193
|
# built-in LSL -> XDF recorder (default on; see interface() checkbox).
|
|
190
194
|
# Created fresh on each Start, kept after Stop so the memo panel can
|
|
@@ -214,6 +218,9 @@ class IntegratedPanel():
|
|
|
214
218
|
atexit.register(self._atexit_cleanup)
|
|
215
219
|
|
|
216
220
|
def _atexit_cleanup(self):
|
|
221
|
+
sequence = getattr(self, "task_sequence", None)
|
|
222
|
+
if sequence is not None:
|
|
223
|
+
sequence.end()
|
|
217
224
|
rec = getattr(self, "lsl_recorder", None)
|
|
218
225
|
if rec is not None:
|
|
219
226
|
try:
|
|
@@ -233,6 +240,11 @@ class IntegratedPanel():
|
|
|
233
240
|
self.journal_outlet.push_sample([msg])
|
|
234
241
|
self.logger.info(f"JOURNAL: {msg}")
|
|
235
242
|
|
|
243
|
+
def _sequence_marker(self, msg):
|
|
244
|
+
if self.journal_outlet is None:
|
|
245
|
+
raise RuntimeError("PLASMA journal LSL stream is unavailable")
|
|
246
|
+
self.journal(msg)
|
|
247
|
+
|
|
236
248
|
def visualizer_interface(self):
|
|
237
249
|
with gr.Column():
|
|
238
250
|
gr.Markdown(
|
|
@@ -444,24 +456,22 @@ class IntegratedPanel():
|
|
|
444
456
|
self.btn_start.click(self.start_collection)
|
|
445
457
|
self.btn_stop.click(self.stop_collection)
|
|
446
458
|
|
|
447
|
-
|
|
448
|
-
|
|
449
459
|
with gr.Accordion(label="🗒️ Journaler", open=False):
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
460
|
+
with gr.Tabs():
|
|
461
|
+
with gr.Tab("Manual markers"):
|
|
462
|
+
j_free = gr.Text(label="Marker text", placeholder="free text",
|
|
463
|
+
show_label=False)
|
|
464
|
+
with gr.Row():
|
|
465
|
+
j_send = gr.Button("✍️ Send marker", variant="primary")
|
|
466
|
+
j_flag = gr.Button("🚩 Flag", scale=0)
|
|
467
|
+
j_send.click(self._record_journal, inputs=j_free, outputs=j_free)
|
|
468
|
+
j_flag.click(self._flag_journal)
|
|
469
|
+
with gr.Tab("Task sequence"):
|
|
470
|
+
self._task_sequence_interface()
|
|
455
471
|
|
|
456
|
-
j_send.click(self._record_journal, inputs=j_free, outputs=j_free)
|
|
457
|
-
j_flag.click(self._flag_journal)
|
|
458
472
|
|
|
459
473
|
with gr.Accordion(label="ℹ️ Memo", open=True):
|
|
460
|
-
self.memo_html =
|
|
461
|
-
elem_id=MEMO_PANEL_ELEM_ID, padding=False)
|
|
462
|
-
timer = gr.Timer(value=1)
|
|
463
|
-
timer.tick(fn=self._render_memo, outputs=self.memo_html,
|
|
464
|
-
show_progress="hidden")
|
|
474
|
+
self.memo_html = self._memo_interface("Session Dashboard", MEMO_PANEL_ELEM_ID)
|
|
465
475
|
|
|
466
476
|
# with gr.Accordion("Help", open=False):
|
|
467
477
|
# with open("./plasma/help.md") as f:
|
|
@@ -469,6 +479,139 @@ class IntegratedPanel():
|
|
|
469
479
|
# # print(help_txt)
|
|
470
480
|
# md = gr.Markdown(help_txt)
|
|
471
481
|
|
|
482
|
+
def focus_interface(self):
|
|
483
|
+
with gr.Row():
|
|
484
|
+
with gr.Column(scale=3, min_width=320):
|
|
485
|
+
self._task_sequence_interface(focus=True)
|
|
486
|
+
with gr.Column(scale=2, min_width=320):
|
|
487
|
+
gr.Markdown("### Memo")
|
|
488
|
+
self._memo_interface("Focus", "plasma-focus-memo")
|
|
489
|
+
|
|
490
|
+
def _register_session_view(self, tab, timer, refresh, outputs, state):
|
|
491
|
+
if not hasattr(self, "_session_views"):
|
|
492
|
+
self._session_views = []
|
|
493
|
+
self._session_views.append(dict(tab=tab, timer=timer, refresh=refresh,
|
|
494
|
+
outputs=outputs, state=state))
|
|
495
|
+
|
|
496
|
+
def wire_session_views(self, tabs):
|
|
497
|
+
"""Only refresh the selected top-level view; repaint it on selection.
|
|
498
|
+
|
|
499
|
+
State caches belong to each browser and display, so one Memo cannot
|
|
500
|
+
consume the other display's update or suppress a new viewer's first paint.
|
|
501
|
+
"""
|
|
502
|
+
for view in self._session_views:
|
|
503
|
+
def select(last, evt: gr.SelectData, view=view):
|
|
504
|
+
active = evt.value == view["tab"]
|
|
505
|
+
values = view["refresh"](last) if active else [gr.skip()] * len(view["outputs"])
|
|
506
|
+
return [gr.Timer(active=active), *values]
|
|
507
|
+
tabs.select(select, inputs=view["state"],
|
|
508
|
+
outputs=[view["timer"], *view["outputs"]], show_progress="hidden")
|
|
509
|
+
|
|
510
|
+
def _memo_interface(self, tab, elem_id):
|
|
511
|
+
content = self._render_memo()
|
|
512
|
+
display = gr.HTML(content, elem_id=elem_id, elem_classes=["plasma-memo"], padding=False)
|
|
513
|
+
last = gr.State(content)
|
|
514
|
+
timer = gr.Timer(value=1, active=(tab == "Session Dashboard"))
|
|
515
|
+
outputs = [display, last]
|
|
516
|
+
timer.tick(self._memo_view_refresh, inputs=last, outputs=outputs, show_progress="hidden")
|
|
517
|
+
self._register_session_view(tab, timer, self._memo_view_refresh, outputs, last)
|
|
518
|
+
return display
|
|
519
|
+
|
|
520
|
+
def _memo_view_refresh(self, last):
|
|
521
|
+
content = self._render_memo()
|
|
522
|
+
return [gr.skip() if content == last else content, content]
|
|
523
|
+
|
|
524
|
+
def _buzzer_refresh(self, last):
|
|
525
|
+
completion = self.task_sequence.snapshot()["completion_id"]
|
|
526
|
+
event = f'<span data-completion-id="{completion}"></span>'
|
|
527
|
+
return [gr.skip() if last == completion else event, completion]
|
|
528
|
+
|
|
529
|
+
def wire_task_buzzer(self):
|
|
530
|
+
# One lightweight notification pump for the browser, independent of the
|
|
531
|
+
# selected tab. No audio data, device work, or extra task timers here.
|
|
532
|
+
event = gr.HTML(elem_id="plasma-buzzer-event", padding=False)
|
|
533
|
+
last = gr.State(None)
|
|
534
|
+
gr.Timer(value=0.5).tick(self._buzzer_refresh, inputs=last, outputs=[event, last],
|
|
535
|
+
show_progress="hidden")
|
|
536
|
+
|
|
537
|
+
def _task_view_refresh(self, last):
|
|
538
|
+
snap = self.task_sequence.snapshot()
|
|
539
|
+
task = snap["task"]
|
|
540
|
+
name = _html.escape(task.name) if task else "Upload a CSV in Journaler to begin"
|
|
541
|
+
status = _html.escape(snap["status"])
|
|
542
|
+
error = _html.escape(snap["error"])
|
|
543
|
+
if self.journal_outlet is None:
|
|
544
|
+
error = "PLASMA journal LSL stream is unavailable. Tasks cannot start."
|
|
545
|
+
content = (f'<div style="text-align:center"><div>Task {snap["index"] + 1 if task else 0}'
|
|
546
|
+
f' / {snap["total"]} · {status}</div>'
|
|
547
|
+
f'<div style="font-size:3rem;font-variant-numeric:tabular-nums">'
|
|
548
|
+
f'{snap["remaining"]}</div><h3>{name}</h3></div>')
|
|
549
|
+
if error:
|
|
550
|
+
content += f'<p role="alert">{error}</p>'
|
|
551
|
+
current = (content, task.instruction if task else "",
|
|
552
|
+
bool(task) and snap["index"] > 0,
|
|
553
|
+
bool(task) and snap["index"] < snap["total"] - 1,
|
|
554
|
+
bool(task) and snap["status"] == "Ready" and self.journal_outlet is not None,
|
|
555
|
+
snap["running"])
|
|
556
|
+
values = [gr.skip() if last is not None and last[i] == value
|
|
557
|
+
else value if i < 2 else gr.update(interactive=value)
|
|
558
|
+
for i, value in enumerate(current)]
|
|
559
|
+
return [*values, current]
|
|
560
|
+
|
|
561
|
+
def _task_sequence_interface(self, focus=False):
|
|
562
|
+
tab = "Focus" if focus else "Session Dashboard"
|
|
563
|
+
if not focus:
|
|
564
|
+
gr.Markdown("Drop a CSV with **Condition**, **Move_Instruction**, and **load** "
|
|
565
|
+
"(seconds). Start collection first to save markers to XDF. "
|
|
566
|
+
"All connected viewers share this task sequence.")
|
|
567
|
+
upload = gr.File(label="Task sequence CSV", file_types=[".csv"], type="filepath")
|
|
568
|
+
display = gr.HTML()
|
|
569
|
+
instruction = gr.Textbox(label="Move instruction", lines=4 if focus else 6,
|
|
570
|
+
max_lines=6 if focus else 20, interactive=False)
|
|
571
|
+
with gr.Row():
|
|
572
|
+
previous = gr.Button("← Previous", interactive=False)
|
|
573
|
+
following = gr.Button("Next →", interactive=False)
|
|
574
|
+
with gr.Row():
|
|
575
|
+
start = gr.Button("Start task (SPACE)", variant="primary", interactive=False,
|
|
576
|
+
elem_id="plasma-focus-task-start" if focus else "plasma-task-start")
|
|
577
|
+
end = gr.Button("End task", interactive=False)
|
|
578
|
+
gr.HTML(BUZZER_CONTROLS, padding=False)
|
|
579
|
+
gr.Markdown("Previous/Next ends a running task and prepares the selected row. "
|
|
580
|
+
"At 00:00 the task ends; advance and start the next task when ready.")
|
|
581
|
+
last = gr.State(None)
|
|
582
|
+
outputs = [display, instruction, previous, following, start, end, last]
|
|
583
|
+
|
|
584
|
+
def load(path, last):
|
|
585
|
+
try:
|
|
586
|
+
tasks = read_tasks(path) if path else []
|
|
587
|
+
self.task_sequence.load(tasks)
|
|
588
|
+
except (ValueError, OSError, UnicodeError, csv.Error) as exc:
|
|
589
|
+
gr.Warning(f"Cannot load task CSV: {exc}")
|
|
590
|
+
return self._task_view_refresh(last)
|
|
591
|
+
|
|
592
|
+
def action(name):
|
|
593
|
+
def callback(last):
|
|
594
|
+
try:
|
|
595
|
+
if name == "previous":
|
|
596
|
+
self.task_sequence.move(-1)
|
|
597
|
+
elif name == "next":
|
|
598
|
+
self.task_sequence.move(1)
|
|
599
|
+
else:
|
|
600
|
+
getattr(self.task_sequence, name)()
|
|
601
|
+
except Exception as exc:
|
|
602
|
+
gr.Warning(f"Task action failed: {exc}")
|
|
603
|
+
return self._task_view_refresh(last)
|
|
604
|
+
return callback
|
|
605
|
+
|
|
606
|
+
if not focus:
|
|
607
|
+
upload.change(load, inputs=[upload, last], outputs=outputs, show_progress="hidden")
|
|
608
|
+
for button, name in [(previous, "previous"), (following, "next"),
|
|
609
|
+
(start, "start"), (end, "end")]:
|
|
610
|
+
button.click(action(name), inputs=last, outputs=outputs, show_progress="hidden")
|
|
611
|
+
timer = gr.Timer(value=0.5, active=not focus)
|
|
612
|
+
timer.tick(self._task_view_refresh, inputs=last, outputs=outputs, show_progress="hidden")
|
|
613
|
+
self._register_session_view(tab, timer, self._task_view_refresh, outputs, last)
|
|
614
|
+
|
|
472
615
|
def _refresh_devices(self):
|
|
473
616
|
active = list(device_config.get_active_table().keys())
|
|
474
617
|
return gr.CheckboxGroup(choices=active, value=active)
|
|
@@ -557,6 +700,7 @@ class IntegratedPanel():
|
|
|
557
700
|
|
|
558
701
|
def stop_collection(self):
|
|
559
702
|
self.sts = "Stopping..."
|
|
703
|
+
self.task_sequence.end()
|
|
560
704
|
for dev in self.available_devices:
|
|
561
705
|
try:
|
|
562
706
|
dev.stop()
|
|
@@ -619,9 +763,7 @@ class IntegratedPanel():
|
|
|
619
763
|
return _status.STOPPED if self._collection_stopped is not None else _status.COLLECTING
|
|
620
764
|
|
|
621
765
|
def _render_memo(self):
|
|
622
|
-
"""
|
|
623
|
-
the last tick, so gr.HTML doesn't tear down + re-render its subtree
|
|
624
|
-
(which reads as a 1 Hz blink)."""
|
|
766
|
+
"""Build Memo HTML; each display owns its own browser-local cache."""
|
|
625
767
|
rec = self.lsl_recorder
|
|
626
768
|
started = self._collection_started
|
|
627
769
|
elapsed = None
|
|
@@ -633,9 +775,6 @@ class IntegratedPanel():
|
|
|
633
775
|
rec.status() if rec is not None else None,
|
|
634
776
|
ext_streams=self._external_lsl_streams(), elapsed=elapsed,
|
|
635
777
|
phase=self.phase)
|
|
636
|
-
if html == getattr(self, "_memo_last", None):
|
|
637
|
-
return gr.skip()
|
|
638
|
-
self._memo_last = html
|
|
639
778
|
return html
|
|
640
779
|
|
|
641
780
|
|
|
@@ -703,4 +842,4 @@ def get_logger(log_dir="data"):
|
|
|
703
842
|
file_handler,
|
|
704
843
|
logging.StreamHandler(),
|
|
705
844
|
])
|
|
706
|
-
return logger
|
|
845
|
+
return logger
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""One browser-local audio handler for both task views."""
|
|
2
|
+
|
|
3
|
+
BUZZER_CONTROLS = """
|
|
4
|
+
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
|
5
|
+
<button type="button" data-plasma-buzzer-test
|
|
6
|
+
style="padding:8px 16px;border:1px solid var(--border-color-primary);border-radius:8px;
|
|
7
|
+
background:var(--button-secondary-background-fill);color:var(--button-secondary-text-color)">
|
|
8
|
+
Test buzzer</button>
|
|
9
|
+
<span data-plasma-buzzer-status role="status" style="font-size:12px"></span>
|
|
10
|
+
</div>
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
BUZZER_JS = r"""
|
|
14
|
+
let audioContext = null;
|
|
15
|
+
let lastCompletion = null;
|
|
16
|
+
let buzzerMessage = '';
|
|
17
|
+
const buzzerStatus = (message) => {
|
|
18
|
+
buzzerMessage = message;
|
|
19
|
+
document.querySelectorAll('[data-plasma-buzzer-status]').forEach(el => {
|
|
20
|
+
if (el.textContent !== message) el.textContent = message;
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
const syncBuzzer = () => {
|
|
24
|
+
document.querySelectorAll('[data-plasma-buzzer-status]').forEach(el => {
|
|
25
|
+
if (el.textContent !== buzzerMessage) el.textContent = buzzerMessage;
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const unlockAudio = () => {
|
|
29
|
+
try {
|
|
30
|
+
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
31
|
+
if (!Audio) throw new Error('Web Audio unavailable');
|
|
32
|
+
if (!audioContext) audioContext = new Audio();
|
|
33
|
+
if (audioContext.state !== 'running') {
|
|
34
|
+
audioContext.resume().catch(() => buzzerStatus('Click Test buzzer to enable audio.'));
|
|
35
|
+
}
|
|
36
|
+
} catch (_) {
|
|
37
|
+
buzzerStatus('Browser audio unavailable.');
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const playBuzzer = async (gesture = false) => {
|
|
41
|
+
if (!gesture && (!audioContext || audioContext.state !== 'running')) {
|
|
42
|
+
buzzerStatus('Click Test buzzer to enable audio.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
unlockAudio();
|
|
46
|
+
if (!audioContext) return;
|
|
47
|
+
try {
|
|
48
|
+
await audioContext.resume();
|
|
49
|
+
if (audioContext.state !== 'running') throw new Error('Audio blocked');
|
|
50
|
+
const now = audioContext.currentTime;
|
|
51
|
+
for (const offset of [0, 0.3]) {
|
|
52
|
+
const oscillator = audioContext.createOscillator();
|
|
53
|
+
const gain = audioContext.createGain();
|
|
54
|
+
oscillator.type = 'sine';
|
|
55
|
+
oscillator.frequency.value = 880;
|
|
56
|
+
gain.gain.setValueAtTime(0, now + offset);
|
|
57
|
+
gain.gain.linearRampToValueAtTime(0.2, now + offset + 0.015);
|
|
58
|
+
gain.gain.linearRampToValueAtTime(0, now + offset + 0.22);
|
|
59
|
+
oscillator.connect(gain);
|
|
60
|
+
gain.connect(audioContext.destination);
|
|
61
|
+
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
|
62
|
+
oscillator.start(now + offset);
|
|
63
|
+
oscillator.stop(now + offset + 0.23);
|
|
64
|
+
}
|
|
65
|
+
buzzerStatus('Buzzer played.');
|
|
66
|
+
} catch (_) {
|
|
67
|
+
buzzerStatus('Click Test buzzer to enable audio.');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const buzzerClick = (event) => {
|
|
71
|
+
if (event.target.closest('[data-plasma-buzzer-test]')) {
|
|
72
|
+
playBuzzer(true);
|
|
73
|
+
} else if (event.target.closest('#plasma-task-start, #plasma-focus-task-start')) {
|
|
74
|
+
unlockAudio();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const checkCompletion = () => {
|
|
78
|
+
const event = document.querySelector('#plasma-buzzer-event [data-completion-id]');
|
|
79
|
+
if (!event) return;
|
|
80
|
+
const id = Number(event.dataset.completionId);
|
|
81
|
+
if (!Number.isFinite(id)) return;
|
|
82
|
+
const previous = lastCompletion;
|
|
83
|
+
lastCompletion = id;
|
|
84
|
+
// First delivery is the baseline; never replay an old completion on load.
|
|
85
|
+
if (previous !== null && id > previous) playBuzzer();
|
|
86
|
+
};
|
|
87
|
+
const buzzerObserver = new MutationObserver(() => { syncBuzzer(); checkCompletion(); });
|
|
88
|
+
buzzerObserver.observe(document.body, {childList: true, subtree: true});
|
|
89
|
+
syncBuzzer();
|
|
90
|
+
checkCompletion();
|
|
91
|
+
document.addEventListener('click', buzzerClick);
|
|
92
|
+
window.addEventListener('pagehide', () => {
|
|
93
|
+
buzzerObserver.disconnect();
|
|
94
|
+
document.removeEventListener('click', buzzerClick);
|
|
95
|
+
if (audioContext) audioContext.close();
|
|
96
|
+
}, {once: true});
|
|
97
|
+
"""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""CSV task sequences and a browser-independent, monotonic countdown."""
|
|
2
|
+
import csv
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import math
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Task:
|
|
11
|
+
name: str
|
|
12
|
+
instruction: str
|
|
13
|
+
seconds: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_tasks(path):
|
|
17
|
+
tasks = []
|
|
18
|
+
with open(path, encoding="utf-8-sig", newline="") as source:
|
|
19
|
+
reader = csv.DictReader(source)
|
|
20
|
+
required = {"Condition", "Move_Instruction", "load"}
|
|
21
|
+
missing = required - set(reader.fieldnames or [])
|
|
22
|
+
if missing:
|
|
23
|
+
raise ValueError("Missing CSV columns: " + ", ".join(sorted(missing)))
|
|
24
|
+
for row in reader:
|
|
25
|
+
name = (row.get("Condition") or "").strip()
|
|
26
|
+
try:
|
|
27
|
+
seconds = float(row.get("load") or "")
|
|
28
|
+
except (ValueError, TypeError):
|
|
29
|
+
seconds = float("nan")
|
|
30
|
+
if not name or not math.isfinite(seconds) or seconds <= 0:
|
|
31
|
+
raise ValueError(f"CSV row ending at line {reader.line_num}: "
|
|
32
|
+
"Condition must be nonempty and load must be a positive finite number.")
|
|
33
|
+
tasks.append(Task(name, (row.get("Move_Instruction") or "").replace("\\n", "\n"), seconds))
|
|
34
|
+
if not tasks:
|
|
35
|
+
raise ValueError("CSV contains no task rows.")
|
|
36
|
+
return tasks
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_remaining(seconds):
|
|
40
|
+
minutes, seconds = divmod(max(0, math.ceil(seconds)), 60)
|
|
41
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TaskSequence:
|
|
45
|
+
"""One shared sequence per acquisition panel; serialize markers and actions.
|
|
46
|
+
|
|
47
|
+
A daemon timer ends the task even when the browser stops polling. Generation
|
|
48
|
+
checks prevent a canceled timer from ending a subsequently started task.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, emit, clock=time.monotonic, timer_factory=threading.Timer):
|
|
52
|
+
self.emit = emit
|
|
53
|
+
self.clock = clock
|
|
54
|
+
self.timer_factory = timer_factory
|
|
55
|
+
self.lock = threading.RLock()
|
|
56
|
+
self.tasks = []
|
|
57
|
+
self.index = 0
|
|
58
|
+
self.status = "No CSV loaded"
|
|
59
|
+
self.deadline = None
|
|
60
|
+
self.timer = None
|
|
61
|
+
self.generation = 0
|
|
62
|
+
self.error = ""
|
|
63
|
+
self.completion_id = 0
|
|
64
|
+
|
|
65
|
+
def load(self, tasks):
|
|
66
|
+
with self.lock:
|
|
67
|
+
self._end()
|
|
68
|
+
self.tasks = list(tasks)
|
|
69
|
+
self.index = 0
|
|
70
|
+
self.status = "Ready" if tasks else "No CSV loaded"
|
|
71
|
+
|
|
72
|
+
def start(self):
|
|
73
|
+
with self.lock:
|
|
74
|
+
if not self.tasks or self.deadline is not None or self.status != "Ready":
|
|
75
|
+
return
|
|
76
|
+
self.error = ""
|
|
77
|
+
self.emit(f"[{self.tasks[self.index].name}] start")
|
|
78
|
+
self.deadline = self.clock() + self.tasks[self.index].seconds
|
|
79
|
+
self.status = "Running"
|
|
80
|
+
self.generation += 1
|
|
81
|
+
self.timer = self.timer_factory(self.tasks[self.index].seconds,
|
|
82
|
+
self._expire, args=(self.generation,))
|
|
83
|
+
self.timer.daemon = True
|
|
84
|
+
self.timer.start()
|
|
85
|
+
|
|
86
|
+
def _expire(self, generation):
|
|
87
|
+
with self.lock:
|
|
88
|
+
if generation == self.generation and self.deadline is not None:
|
|
89
|
+
self._end("Completed")
|
|
90
|
+
|
|
91
|
+
def _end(self, status="Ended"):
|
|
92
|
+
if self.deadline is None:
|
|
93
|
+
return
|
|
94
|
+
self.deadline = None
|
|
95
|
+
self.generation += 1
|
|
96
|
+
if self.timer is not None:
|
|
97
|
+
self.timer.cancel()
|
|
98
|
+
self.timer = None
|
|
99
|
+
self.status = status
|
|
100
|
+
if status == "Completed":
|
|
101
|
+
self.completion_id += 1
|
|
102
|
+
try:
|
|
103
|
+
self.emit(f"[{self.tasks[self.index].name}] end")
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
self.error = f"End marker failed: {exc}"
|
|
106
|
+
|
|
107
|
+
def end(self):
|
|
108
|
+
with self.lock:
|
|
109
|
+
self._end()
|
|
110
|
+
|
|
111
|
+
def move(self, offset):
|
|
112
|
+
with self.lock:
|
|
113
|
+
target = self.index + offset
|
|
114
|
+
if not 0 <= target < len(self.tasks):
|
|
115
|
+
return
|
|
116
|
+
self._end()
|
|
117
|
+
self.index = target
|
|
118
|
+
self.status = "Ready"
|
|
119
|
+
|
|
120
|
+
def snapshot(self):
|
|
121
|
+
with self.lock:
|
|
122
|
+
if self.deadline is not None and self.clock() >= self.deadline:
|
|
123
|
+
self._end("Completed")
|
|
124
|
+
task = self.tasks[self.index] if self.tasks else None
|
|
125
|
+
remaining = (max(0, self.deadline - self.clock()) if self.deadline is not None
|
|
126
|
+
else task.seconds if task and self.status == "Ready" else 0)
|
|
127
|
+
return dict(task=task, index=self.index, total=len(self.tasks),
|
|
128
|
+
remaining=format_remaining(remaining), status=self.status,
|
|
129
|
+
error=self.error, running=self.deadline is not None,
|
|
130
|
+
completion_id=self.completion_id)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|