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.
Files changed (78) hide show
  1. {plasma_app-2.2.4 → plasma_app-2.3.0}/PKG-INFO +1 -1
  2. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__init__.py +1 -1
  3. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__main__.py +27 -1
  4. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/integrated_panel.py +170 -31
  5. plasma_app-2.3.0/plasma/task_buzzer.py +97 -0
  6. plasma_app-2.3.0/plasma/task_sequence.py +130 -0
  7. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/PKG-INFO +1 -1
  8. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/SOURCES.txt +2 -0
  9. {plasma_app-2.2.4 → plasma_app-2.3.0}/LICENSE +0 -0
  10. {plasma_app-2.2.4 → plasma_app-2.3.0}/README.md +0 -0
  11. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__pyinstaller/__init__.py +0 -0
  12. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/__pyinstaller/hook-plasma.py +0 -0
  13. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/api.py +0 -0
  14. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/app_context.py +0 -0
  15. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/build_info.py +0 -0
  16. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/config.py +0 -0
  17. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/ctl.py +0 -0
  18. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/desktop_shortcut.py +0 -0
  19. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/__init__.py +0 -0
  20. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/bitalino.py +0 -0
  21. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/__init__.py +0 -0
  22. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/ble_scan.py +0 -0
  23. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/bleak_probe.py +0 -0
  24. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/bleak_reconnect_test.py +0 -0
  25. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/config.py +0 -0
  26. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/detect.py +0 -0
  27. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/device.py +0 -0
  28. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/__init__.py +0 -0
  29. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/__main__.py +0 -0
  30. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/clocksync.py +0 -0
  31. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/options.py +0 -0
  32. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/extract/pipeline.py +0 -0
  33. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/formats/__init__.py +0 -0
  34. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/gyro_bias.py +0 -0
  35. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/nus_sim.py +0 -0
  36. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/nus_stream.py +0 -0
  37. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/__init__.py +0 -0
  38. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/_common.py +0 -0
  39. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/clocksync.py +0 -0
  40. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/control.py +0 -0
  41. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/downloader.py +0 -0
  42. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/extractor.py +0 -0
  43. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/imu.py +0 -0
  44. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/sqc.py +0 -0
  45. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/tools.py +0 -0
  46. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/uuid_tools.py +0 -0
  47. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/panels/viewer.py +0 -0
  48. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/quaternion.py +0 -0
  49. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/records.py +0 -0
  50. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense/signal_quality.py +0 -0
  51. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/__init__.py +0 -0
  52. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/config.py +0 -0
  53. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/device.py +0 -0
  54. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/fake_stream.py +0 -0
  55. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/msense_demo/faults.py +0 -0
  56. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/obs.py +0 -0
  57. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/pupil_labs.py +0 -0
  58. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/qb2.py +0 -0
  59. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/shimmer.py +0 -0
  60. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/devices/template.py +0 -0
  61. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/journal.py +0 -0
  62. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/lsl_recorder.py +0 -0
  63. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/lsl_session.py +0 -0
  64. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/lsl_util.py +0 -0
  65. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/plot_util.py +0 -0
  66. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/plugins.py +0 -0
  67. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.icns +0 -0
  68. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.ico +0 -0
  69. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.png +0 -0
  70. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/resources/icons/plasma.svg +0 -0
  71. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/status.py +0 -0
  72. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma/xdf_writer.py +0 -0
  73. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/dependency_links.txt +0 -0
  74. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/entry_points.txt +0 -0
  75. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/requires.txt +0 -0
  76. {plasma_app-2.2.4 → plasma_app-2.3.0}/plasma_app.egg-info/top_level.txt +0 -0
  77. {plasma_app-2.2.4 → plasma_app-2.3.0}/pyproject.toml +0 -0
  78. {plasma_app-2.2.4 → plasma_app-2.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plasma-app
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: PLASMA — Platform for LSL-based Acquisition of Sensor Metrics and Analytics
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/YuyiChang/PLASMA
@@ -1,4 +1,4 @@
1
- __version__ = "2.2.4"
1
+ __version__ = "2.3.0"
2
2
 
3
3
  # Short git commit hash baked in by CI (.github/workflows/build.yml) just
4
4
  # before a release build is frozen with PyInstaller — a frozen bundle ships no
@@ -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) as app:
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
- f"#{MEMO_PANEL_ELEM_ID} .prose{{max-width:none}}"
68
- f"#{MEMO_PANEL_ELEM_ID} .m-wrap{{font:13px/1.5 ui-monospace,Menlo,Consolas,monospace}}"
69
- f"#{MEMO_PANEL_ELEM_ID} .m-hdr{{font-weight:600;margin:1px 0 5px}}"
70
- f"#{MEMO_PANEL_ELEM_ID} .m-row{{margin:4px 0}}"
71
- f"#{MEMO_PANEL_ELEM_ID} .m-name{{font-weight:600}}"
72
- f"#{MEMO_PANEL_ELEM_ID} .m-tag{{opacity:.6;font-weight:400;margin-left:7px}}"
73
- f"#{MEMO_PANEL_ELEM_ID} .m-sub{{color:#555;padding-left:18px;font-size:12px}}"
74
- f"#{MEMO_PANEL_ELEM_ID} .m-sub.rec{{color:{_STATUS_HEX['guidance']}}}"
75
- f"#{MEMO_PANEL_ELEM_ID} .m-sub.guidance{{color:{_STATUS_HEX['guidance']}}}"
76
- f"#{MEMO_PANEL_ELEM_ID} .m-dim{{opacity:.55}}"
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
- j_free = gr.Text(label="Marker text", placeholder="free text",
451
- show_label=False)
452
- with gr.Row():
453
- j_send = gr.Button("✍️ Send marker", variant="primary")
454
- j_flag = gr.Button("🚩 Flag", scale=0)
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 = gr.HTML(self._render_memo(),
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
- """HTML for the memo panel. Returns gr.skip() when nothing changed since
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plasma-app
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: PLASMA — Platform for LSL-based Acquisition of Sensor Metrics and Analytics
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/YuyiChang/PLASMA
@@ -17,6 +17,8 @@ plasma/lsl_util.py
17
17
  plasma/plot_util.py
18
18
  plasma/plugins.py
19
19
  plasma/status.py
20
+ plasma/task_buzzer.py
21
+ plasma/task_sequence.py
20
22
  plasma/xdf_writer.py
21
23
  plasma/__pyinstaller/__init__.py
22
24
  plasma/__pyinstaller/hook-plasma.py
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