opsecsystems commited on
Commit
bc0830d
·
verified ·
1 Parent(s): 29d1af2

Upload 3 files

Browse files
Files changed (3) hide show
  1. backend.py +861 -0
  2. requirements.txt +10 -0
  3. site.html +2235 -0
backend.py ADDED
@@ -0,0 +1,861 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EEG Mental Imagery Classification Backend
3
+ Full-stack server: OpenBCI Cyton+Daisy (16ch), LSL markers, neurofeedback, websocket API
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import math
10
+ import os
11
+ import queue
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from dataclasses import asdict, dataclass, field
16
+ from datetime import datetime
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ import numpy as np
22
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import FileResponse, JSONResponse
25
+ import uvicorn
26
+
27
+ # ─── Optional heavy imports (graceful degradation) ───────────────────────────
28
+ try:
29
+ from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds, BrainFlowError
30
+ from brainflow.data_filter import DataFilter, FilterTypes, DetrendOperations, WindowOperations
31
+ BRAINFLOW_AVAILABLE = True
32
+ except ImportError:
33
+ BRAINFLOW_AVAILABLE = False
34
+ logging.warning("BrainFlow not installed – running in SIMULATION mode")
35
+
36
+ try:
37
+ from pylsl import StreamInfo, StreamOutlet, StreamInlet, resolve_streams, cf_string, cf_float32
38
+ LSL_AVAILABLE = True
39
+ except ImportError:
40
+ LSL_AVAILABLE = False
41
+ logging.warning("pylsl not installed – LSL streaming disabled")
42
+
43
+ try:
44
+ import scipy.signal as signal
45
+ from scipy.signal import welch, butter, filtfilt
46
+ SCIPY_AVAILABLE = True
47
+ except ImportError:
48
+ SCIPY_AVAILABLE = False
49
+
50
+ try:
51
+ from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
52
+ from sklearn.preprocessing import StandardScaler
53
+ from sklearn.pipeline import Pipeline
54
+ SKLEARN_AVAILABLE = True
55
+ except ImportError:
56
+ SKLEARN_AVAILABLE = False
57
+
58
+ # ─── Logging ─────────────────────────────────────────────────────────────────
59
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s │ %(levelname)s │ %(message)s")
60
+ logger = logging.getLogger("EEG-Backend")
61
+
62
+ # ─── Constants ────────────────────────────────────────────────────────────────
63
+ SAMPLE_RATE = 250 # Hz – Cyton+Daisy
64
+ N_CHANNELS = 16
65
+ EPOCH_DURATION = 4.0 # seconds
66
+ BASELINE_DURATION = 1.0 # seconds pre-stimulus
67
+ BUFFER_SECONDS = 30
68
+ ALPHA_BAND = (8, 13)
69
+ BETA_BAND = (13, 30)
70
+ THETA_BAND = (4, 8)
71
+ GAMMA_BAND = (30, 45)
72
+
73
+ # Standard 10-20 positions for 16-ch parieto-occipital cap
74
+ ELECTRODE_POSITIONS_16CH = {
75
+ "P3": (-0.30, 0.50), "Pz": (0.00, 0.58), "P4": (0.30, 0.50),
76
+ "P7": (-0.55, 0.42), "P8": (0.55, 0.42),
77
+ "PO3": (-0.22, 0.38), "POz": (0.00, 0.38), "PO4": (0.22, 0.38),
78
+ "PO7": (-0.40, 0.28), "PO8": (0.40, 0.28),
79
+ "O1": (-0.20, 0.18), "Oz": (0.00, 0.18), "O2": (0.20, 0.18),
80
+ "CP3": (-0.25, 0.68), "CPz": (0.00, 0.72), "CP4": (0.25, 0.68),
81
+ }
82
+ CHANNEL_NAMES = list(ELECTRODE_POSITIONS_16CH.keys())
83
+
84
+ # ─── Data Structures ──────────────────────────────────────────────────────────
85
+
86
+ class SessionPhase(str, Enum):
87
+ IDLE = "IDLE"
88
+ BASELINE = "BASELINE"
89
+ ACQUISITION = "ACQUISITION"
90
+ FEEDBACK = "FEEDBACK"
91
+ CONVERGENCE = "CONVERGENCE"
92
+ LIBRARY = "LIBRARY"
93
+
94
+ @dataclass
95
+ class Trial:
96
+ trial_id: int
97
+ class_label: str
98
+ onset_time: float
99
+ quality_score: Optional[int] = None # 1-5 self-report
100
+ eeg_epoch: Optional[np.ndarray] = None # (n_channels, n_samples)
101
+ features: Optional[np.ndarray] = None
102
+ nf_score: Optional[float] = None # neurofeedback distance [0,1]
103
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
104
+
105
+ def to_dict(self) -> dict:
106
+ return {
107
+ "trial_id": self.trial_id,
108
+ "class_label": self.class_label,
109
+ "onset_time": self.onset_time,
110
+ "quality_score": self.quality_score,
111
+ "nf_score": self.nf_score,
112
+ "timestamp": self.timestamp,
113
+ }
114
+
115
+ @dataclass
116
+ class NeuralState:
117
+ """Stable neural state = cluster centroid in feature space"""
118
+ class_label: str
119
+ centroid: np.ndarray
120
+ covariance: np.ndarray
121
+ n_trials: int
122
+ convergence_score: float # how tight the cluster is [0,1]
123
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
124
+
125
+ @dataclass
126
+ class SessionConfig:
127
+ subject_id: str = "S001"
128
+ session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
129
+ class_a: str = "Class_A"
130
+ class_b: str = "Class_B"
131
+ n_trials_per_class: int = 20
132
+ min_quality_for_model: int = 4 # only use trials rated ≥ 4
133
+ feedback_threshold: float = 0.65 # similarity to converge
134
+ port: str = "/dev/ttyUSB0"
135
+ board_id: int = 2 # 2=Cyton+Daisy
136
+ simulate: bool = not BRAINFLOW_AVAILABLE
137
+
138
+ # ─── Signal Processing ────────────────────────────────────────────────────────
139
+
140
+ class SignalProcessor:
141
+ def __init__(self, srate: int = SAMPLE_RATE, n_channels: int = N_CHANNELS):
142
+ self.srate = srate
143
+ self.n_channels = n_channels
144
+ self._design_filters()
145
+
146
+ def _design_filters(self):
147
+ """Pre-compute Butterworth filters"""
148
+ nyq = self.srate / 2
149
+ self.bp_coefs = {}
150
+ for name, (lo, hi) in [("broad", (1, 45)), ("alpha", ALPHA_BAND),
151
+ ("beta", BETA_BAND), ("theta", THETA_BAND)]:
152
+ b, a = butter(4, [lo/nyq, hi/nyq], btype="band")
153
+ self.bp_coefs[name] = (b, a)
154
+ # Notch 50/60 Hz
155
+ b60, a60 = butter(4, [58/nyq, 62/nyq], btype="bandstop")
156
+ b50, a50 = butter(4, [48/nyq, 52/nyq], btype="bandstop")
157
+ self.notch_60 = (b60, a60)
158
+ self.notch_50 = (b50, a50)
159
+
160
+ def preprocess(self, epoch: np.ndarray) -> np.ndarray:
161
+ """Band-pass + notch filter. epoch: (n_ch, n_samples)"""
162
+ out = epoch.copy().astype(float)
163
+ b, a = self.bp_coefs["broad"]
164
+ bn60, an60 = self.notch_60
165
+ for ch in range(out.shape[0]):
166
+ out[ch] = filtfilt(b, a, out[ch])
167
+ out[ch] = filtfilt(bn60, an60, out[ch])
168
+ # Remove DC
169
+ out[ch] -= out[ch].mean()
170
+ return out
171
+
172
+ def compute_psd(self, epoch: np.ndarray, fmax: float = 50.0) -> Tuple[np.ndarray, np.ndarray]:
173
+ """Welch PSD per channel. Returns (freqs, psd): psd shape (n_ch, n_freqs)"""
174
+ nperseg = min(self.srate, epoch.shape[1])
175
+ freqs, psd = welch(epoch, fs=self.srate, nperseg=nperseg, axis=1)
176
+ mask = freqs <= fmax
177
+ return freqs[mask], psd[:, mask]
178
+
179
+ def band_power(self, epoch: np.ndarray) -> Dict[str, np.ndarray]:
180
+ """Band power per channel per band. Returns dict of (n_ch,) arrays"""
181
+ freqs, psd = self.compute_psd(epoch)
182
+ powers = {}
183
+ for name, (lo, hi) in [("alpha", ALPHA_BAND), ("beta", BETA_BAND),
184
+ ("theta", THETA_BAND), ("gamma", GAMMA_BAND)]:
185
+ idx = np.logical_and(freqs >= lo, freqs <= hi)
186
+ powers[name] = np.trapz(psd[:, idx], freqs[idx], axis=1)
187
+ return powers
188
+
189
+ def extract_features(self, epoch: np.ndarray) -> np.ndarray:
190
+ """
191
+ Feature vector: band-power ratios + log-band-powers + covariance diagonal
192
+ Returns 1D numpy array
193
+ """
194
+ clean = self.preprocess(epoch)
195
+ bp = self.band_power(clean)
196
+ alpha = bp["alpha"] + 1e-10
197
+ beta = bp["beta"] + 1e-10
198
+ theta = bp["theta"] + 1e-10
199
+ gamma = bp["gamma"] + 1e-10
200
+
201
+ feats = []
202
+ feats.extend(np.log(alpha)) # 16 log-alpha
203
+ feats.extend(np.log(beta)) # 16 log-beta
204
+ feats.extend(np.log(theta)) # 16 log-theta
205
+ feats.extend(beta / alpha) # 16 beta/alpha
206
+ feats.extend(theta / alpha) # 16 theta/alpha
207
+ # Covariance diagonal (channel variances)
208
+ cov = np.cov(clean)
209
+ feats.extend(np.log(np.diag(cov) + 1e-10)) # 16 log-var
210
+
211
+ # Frontal asymmetry (not applicable for occipital, skip ratio index)
212
+ return np.array(feats, dtype=float)
213
+
214
+ def compute_topomap(self, epoch: np.ndarray) -> Dict[str, float]:
215
+ """Return per-channel alpha power for topomap display"""
216
+ clean = self.preprocess(epoch)
217
+ bp = self.band_power(clean)
218
+ return {name: float(val) for name, val in zip(CHANNEL_NAMES, bp["alpha"])}
219
+
220
+ def artifact_rejection(self, epoch: np.ndarray, threshold_uv: float = 100.0) -> bool:
221
+ """Returns True if epoch is clean"""
222
+ peak_to_peak = epoch.max(axis=1) - epoch.min(axis=1)
223
+ return bool(np.all(peak_to_peak < threshold_uv * 1e-6)) # BrainFlow in Volts
224
+
225
+ # ─── Neurofeedback Engine ─────────────────────────────────────────────────────
226
+
227
+ class NeurofeedbackEngine:
228
+ """
229
+ Computes similarity between current epoch features and stored neural state centroids.
230
+ Score ∈ [0,1] where 1 = perfectly matching the target state.
231
+ """
232
+ def __init__(self):
233
+ self.states: Dict[str, NeuralState] = {}
234
+
235
+ def update_state(self, class_label: str, features_list: List[np.ndarray]):
236
+ """Build/update stable state from a list of high-quality feature vectors"""
237
+ if len(features_list) < 3:
238
+ return
239
+ mat = np.stack(features_list)
240
+ centroid = mat.mean(axis=0)
241
+ cov = np.cov(mat.T) + np.eye(mat.shape[1]) * 1e-6
242
+ # Convergence = 1 - normalised mean intra-cluster distance
243
+ dists = [np.linalg.norm(f - centroid) for f in features_list]
244
+ convergence = max(0.0, 1.0 - np.mean(dists) / (np.std(dists) + 1.0))
245
+ self.states[class_label] = NeuralState(
246
+ class_label=class_label,
247
+ centroid=centroid,
248
+ covariance=cov,
249
+ n_trials=len(features_list),
250
+ convergence_score=float(convergence),
251
+ )
252
+ logger.info(f"Neural state updated for {class_label}: convergence={convergence:.3f}")
253
+
254
+ def compute_similarity(self, features: np.ndarray, class_label: str) -> float:
255
+ """Mahalanobis-based similarity to target state"""
256
+ if class_label not in self.states:
257
+ return 0.0
258
+ state = self.states[class_label]
259
+ diff = features - state.centroid
260
+ try:
261
+ inv_cov = np.linalg.pinv(state.covariance)
262
+ dist = float(np.sqrt(diff @ inv_cov @ diff))
263
+ # Sigmoid mapping: dist=0 → score=1, dist=5 → score≈0.5
264
+ score = 1.0 / (1.0 + dist / 3.0)
265
+ except Exception:
266
+ score = 0.0
267
+ return float(np.clip(score, 0.0, 1.0))
268
+
269
+ def get_feedback_audio_params(self, score: float) -> dict:
270
+ """Returns audio synthesis params based on NF score"""
271
+ freq_hz = 200 + score * 600 # 200–800 Hz
272
+ volume = 0.3 + score * 0.7 # 0.3–1.0
273
+ tone = "positive" if score > 0.65 else ("neutral" if score > 0.35 else "negative")
274
+ return {"freq_hz": round(freq_hz, 1), "volume": round(volume, 3), "tone": tone}
275
+
276
+ # ─── LDA Classifier ───────────────────────────────────────────────────────────
277
+
278
+ class EEGClassifier:
279
+ def __init__(self):
280
+ if SKLEARN_AVAILABLE:
281
+ self.model = Pipeline([
282
+ ("scaler", StandardScaler()),
283
+ ("lda", LinearDiscriminantAnalysis(solver="svd")),
284
+ ])
285
+ self.is_trained = False
286
+ self.classes_: List[str] = []
287
+
288
+ def fit(self, X: np.ndarray, y: List[str]):
289
+ if not SKLEARN_AVAILABLE or len(X) < 6:
290
+ return False
291
+ self.model.fit(X, y)
292
+ self.is_trained = True
293
+ self.classes_ = list(np.unique(y))
294
+ logger.info(f"LDA trained on {len(X)} epochs, classes={self.classes_}")
295
+ return True
296
+
297
+ def predict_proba(self, features: np.ndarray) -> Dict[str, float]:
298
+ if not self.is_trained:
299
+ return {}
300
+ proba = self.model.predict_proba(features.reshape(1, -1))[0]
301
+ return {cls: float(p) for cls, p in zip(self.classes_, proba)}
302
+
303
+ # ─── LSL Manager ──────────────────────────────────────────────────────────────
304
+
305
+ class LSLManager:
306
+ def __init__(self, stream_name: str = "EEGMarkers"):
307
+ self.outlet = None
308
+ self.eeg_outlet = None
309
+ if LSL_AVAILABLE:
310
+ self._init_marker_stream(stream_name)
311
+ self._init_eeg_stream()
312
+
313
+ def _init_marker_stream(self, name: str):
314
+ info = StreamInfo(name, "Markers", 1, 0, cf_string, f"markers-{name}")
315
+ info.desc().append_child_value("manufacturer", "EEG-MI-Backend")
316
+ self.outlet = StreamOutlet(info)
317
+ logger.info(f"LSL marker stream '{name}' opened")
318
+
319
+ def _init_eeg_stream(self):
320
+ info = StreamInfo("EEG-MI", "EEG", N_CHANNELS, SAMPLE_RATE, cf_float32, "eeg-mi")
321
+ chns = info.desc().append_child("channels")
322
+ for name in CHANNEL_NAMES:
323
+ ch = chns.append_child("channel")
324
+ ch.append_child_value("label", name)
325
+ ch.append_child_value("unit", "microvolts")
326
+ ch.append_child_value("type", "EEG")
327
+ self.eeg_outlet = StreamOutlet(info, 32, 360)
328
+ logger.info("LSL EEG stream opened")
329
+
330
+ def push_marker(self, marker: str):
331
+ if self.outlet:
332
+ self.outlet.push_sample([marker])
333
+ logger.debug(f"LSL marker: {marker}")
334
+
335
+ def push_eeg_chunk(self, chunk: np.ndarray):
336
+ """chunk: (n_samples, n_channels)"""
337
+ if self.eeg_outlet and chunk.size > 0:
338
+ self.eeg_outlet.push_chunk(chunk.tolist())
339
+
340
+ # ─── Board Manager ────────────────────────────────────────────────────────────
341
+
342
+ class BoardManager:
343
+ """Handles Cyton+Daisy board or simulation"""
344
+
345
+ def __init__(self, config: SessionConfig):
346
+ self.config = config
347
+ self.board = None
348
+ self.board_id = config.board_id
349
+ self.srate = SAMPLE_RATE
350
+ self._running = False
351
+ self._thread: Optional[threading.Thread] = None
352
+ self._buffer = deque(maxlen=BUFFER_SECONDS * SAMPLE_RATE)
353
+ self._lock = threading.Lock()
354
+ self.connected = False
355
+
356
+ # Simulation state
357
+ self._sim_t = 0.0
358
+ self._sim_phase = 0.0
359
+
360
+ def connect(self) -> bool:
361
+ if self.config.simulate:
362
+ logger.info("Board: SIMULATION mode active")
363
+ self.connected = True
364
+ return True
365
+
366
+ if not BRAINFLOW_AVAILABLE:
367
+ logger.error("BrainFlow not available; cannot connect to hardware")
368
+ return False
369
+
370
+ params = BrainFlowInputParams()
371
+ params.serial_port = self.config.port
372
+ try:
373
+ BoardShim.enable_dev_board_logger()
374
+ self.board = BoardShim(self.board_id, params)
375
+ self.board.prepare_session()
376
+ self.board.start_stream(45000)
377
+ self.srate = BoardShim.get_sampling_rate(self.board_id)
378
+ self.connected = True
379
+ logger.info(f"Cyton+Daisy connected: {self.config.port} @ {self.srate} Hz")
380
+ return True
381
+ except BrainFlowError as e:
382
+ logger.error(f"Board connection failed: {e}")
383
+ return False
384
+
385
+ def disconnect(self):
386
+ self._running = False
387
+ if self.board and BRAINFLOW_AVAILABLE:
388
+ try:
389
+ self.board.stop_stream()
390
+ self.board.release_session()
391
+ except Exception:
392
+ pass
393
+ self.connected = False
394
+
395
+ def start_acquisition(self):
396
+ self._running = True
397
+ self._thread = threading.Thread(target=self._acquisition_loop, daemon=True)
398
+ self._thread.start()
399
+
400
+ def stop_acquisition(self):
401
+ self._running = False
402
+
403
+ def _acquisition_loop(self):
404
+ while self._running:
405
+ if self.config.simulate:
406
+ chunk = self._generate_sim_chunk(50) # 50 samples at a time
407
+ else:
408
+ chunk = self._read_board_chunk()
409
+
410
+ if chunk is not None and chunk.shape[1] > 0:
411
+ with self._lock:
412
+ for s in range(chunk.shape[1]):
413
+ self._buffer.append(chunk[:, s])
414
+ time.sleep(0.05)
415
+
416
+ def _read_board_chunk(self) -> Optional[np.ndarray]:
417
+ try:
418
+ data = self.board.get_board_data()
419
+ eeg_channels = BoardShim.get_eeg_channels(self.board_id)
420
+ if data.shape[1] == 0:
421
+ return None
422
+ return data[eeg_channels[:N_CHANNELS], :] # (16, n_samples)
423
+ except Exception as e:
424
+ logger.warning(f"Board read error: {e}")
425
+ return None
426
+
427
+ def _generate_sim_chunk(self, n_samples: int) -> np.ndarray:
428
+ """Realistic EEG simulation: alpha rhythm + noise + ocular artifacts"""
429
+ chunk = np.zeros((N_CHANNELS, n_samples))
430
+ t = np.linspace(self._sim_t, self._sim_t + n_samples/SAMPLE_RATE, n_samples)
431
+ self._sim_t += n_samples / SAMPLE_RATE
432
+
433
+ for ch in range(N_CHANNELS):
434
+ # Alpha (8-12 Hz) with spatial gradient
435
+ alpha_amp = (0.5 + 0.5 * math.sin(ch * 0.4)) * 15e-6
436
+ alpha = alpha_amp * np.sin(2 * np.pi * 10 * t + ch * 0.3)
437
+ # Beta (13-25 Hz)
438
+ beta = 5e-6 * np.sin(2 * np.pi * 20 * t + ch * 0.6)
439
+ # Theta (4-8 Hz) – stronger in frontal (not this cap, but simulated)
440
+ theta = 8e-6 * np.sin(2 * np.pi * 6 * t)
441
+ # White noise
442
+ noise = np.random.randn(n_samples) * 3e-6
443
+ chunk[ch] = alpha + beta + theta + noise
444
+
445
+ # Occasional blink artifact on ch 0-1
446
+ if np.random.rand() < 0.02:
447
+ idx = np.random.randint(0, max(1, n_samples - 20))
448
+ pulse = np.hanning(20) * 150e-6
449
+ end = min(idx + 20, n_samples)
450
+ chunk[0, idx:end] += pulse[:end-idx]
451
+ chunk[1, idx:end] += pulse[:end-idx] * 0.5
452
+
453
+ return chunk
454
+
455
+ def get_epoch(self, duration: float, offset: float = 0.0) -> Optional[np.ndarray]:
456
+ """Extract latest epoch of `duration` seconds. Returns (n_ch, n_samples)"""
457
+ n_wanted = int((duration + offset) * SAMPLE_RATE)
458
+ with self._lock:
459
+ buf = list(self._buffer)
460
+ if len(buf) < n_wanted:
461
+ return None
462
+ arr = np.array(buf[-n_wanted:]).T # (n_ch, n_samples)
463
+ # Return only the epoch portion (skip offset)
464
+ n_offset = int(offset * SAMPLE_RATE)
465
+ return arr[:, n_offset:]
466
+
467
+ def get_psd_snapshot(self) -> Optional[np.ndarray]:
468
+ """Latest 2-second window for live display (n_ch, n_samples)"""
469
+ return self.get_epoch(2.0)
470
+
471
+ # ─── Session Manager ──────────────────────────────────────────────────────────
472
+
473
+ class SessionManager:
474
+ def __init__(self):
475
+ self.config = SessionConfig()
476
+ self.phase = SessionPhase.IDLE
477
+ self.trials: List[Trial] = []
478
+ self.trial_counter = 0
479
+ self.current_class: Optional[str] = None
480
+ self.target_class: Optional[str] = None
481
+
482
+ self.processor = SignalProcessor()
483
+ self.nf_engine = NeurofeedbackEngine()
484
+ self.classifier = EEGClassifier()
485
+ self.lsl = LSLManager()
486
+ self.board = BoardManager(self.config)
487
+
488
+ self._ws_clients: List[WebSocket] = []
489
+ self._event_queue: asyncio.Queue = asyncio.Queue()
490
+ self._current_loop: Optional[asyncio.AbstractEventLoop] = None
491
+
492
+ # ── Connection Management ──────────────────────────────────────────────
493
+
494
+ def connect_board(self) -> dict:
495
+ ok = self.board.connect()
496
+ if ok:
497
+ self.board.start_acquisition()
498
+ self.lsl.push_marker("SESSION_START")
499
+ return {"status": "connected" if ok else "error", "simulate": self.config.simulate}
500
+
501
+ def disconnect_board(self):
502
+ self.board.disconnect()
503
+ self.lsl.push_marker("SESSION_END")
504
+
505
+ # ── Trial Management ───────────────────────────────────────────────────
506
+
507
+ def start_trial(self, class_label: str) -> Trial:
508
+ self.trial_counter += 1
509
+ onset = time.time()
510
+ trial = Trial(
511
+ trial_id=self.trial_counter,
512
+ class_label=class_label,
513
+ onset_time=onset,
514
+ )
515
+ self.trials.append(trial)
516
+ self.current_class = class_label
517
+ self.phase = SessionPhase.ACQUISITION
518
+
519
+ marker = f"TRIAL_START;class={class_label};id={self.trial_counter}"
520
+ self.lsl.push_marker(marker)
521
+ logger.info(f"Trial {self.trial_counter} started: {class_label}")
522
+ return trial
523
+
524
+ def end_trial(self, quality: int) -> dict:
525
+ """Called after subject rates the trial 1-5"""
526
+ active = self._get_active_trial()
527
+ if not active:
528
+ return {"error": "no active trial"}
529
+
530
+ active.quality_score = quality
531
+ self.lsl.push_marker(f"TRIAL_END;id={active.trial_id};quality={quality}")
532
+
533
+ # Extract epoch (4s before now, skip first 0.5s baseline)
534
+ epoch = self.board.get_epoch(EPOCH_DURATION, offset=0.5)
535
+ if epoch is not None:
536
+ # Artifact check
537
+ clean = self.processor.artifact_rejection(epoch)
538
+ if clean:
539
+ active.eeg_epoch = epoch
540
+ active.features = self.processor.extract_features(epoch)
541
+ logger.info(f"Trial {active.trial_id}: clean epoch extracted, quality={quality}")
542
+ else:
543
+ logger.warning(f"Trial {active.trial_id}: artifact detected – epoch discarded")
544
+ self.lsl.push_marker(f"ARTIFACT;id={active.trial_id}")
545
+
546
+ # Update model if quality ≥ threshold
547
+ self._maybe_update_model(active.class_label)
548
+
549
+ # Compute NF score if model exists
550
+ nf = 0.0
551
+ if active.features is not None:
552
+ nf = self.nf_engine.compute_similarity(active.features, active.class_label)
553
+ active.nf_score = nf
554
+
555
+ return {
556
+ "trial_id": active.trial_id,
557
+ "quality": quality,
558
+ "nf_score": round(nf, 4),
559
+ "feedback": self.nf_engine.get_feedback_audio_params(nf),
560
+ "model_updated": active.class_label in self.nf_engine.states,
561
+ }
562
+
563
+ def _get_active_trial(self) -> Optional[Trial]:
564
+ for t in reversed(self.trials):
565
+ if t.quality_score is None:
566
+ return t
567
+ return None
568
+
569
+ def _maybe_update_model(self, class_label: str):
570
+ """Rebuild neural state from high-quality trials"""
571
+ good_features = [
572
+ t.features for t in self.trials
573
+ if t.class_label == class_label
574
+ and t.quality_score is not None
575
+ and t.quality_score >= self.config.min_quality_for_model
576
+ and t.features is not None
577
+ ]
578
+ if len(good_features) >= 3:
579
+ self.nf_engine.update_state(class_label, good_features)
580
+ # Retrain classifier if both classes have data
581
+ self._retrain_classifier()
582
+
583
+ def _retrain_classifier(self):
584
+ X, y = [], []
585
+ for t in self.trials:
586
+ if t.features is not None and t.quality_score is not None \
587
+ and t.quality_score >= self.config.min_quality_for_model:
588
+ X.append(t.features)
589
+ y.append(t.class_label)
590
+ if len(set(y)) >= 2:
591
+ self.classifier.fit(np.array(X), y)
592
+
593
+ # ── Live Signals ───────────────────────────────────────────────────────
594
+
595
+ def get_live_signals(self) -> dict:
596
+ epoch = self.board.get_psd_snapshot()
597
+ if epoch is None:
598
+ # Return synthetic zeros
599
+ return {
600
+ "channels": CHANNEL_NAMES,
601
+ "alpha_power": [0.0] * N_CHANNELS,
602
+ "beta_power": [0.0] * N_CHANNELS,
603
+ "theta_power": [0.0] * N_CHANNELS,
604
+ "raw_samples": [[0.0] * 50] * 4, # 4 channels preview
605
+ "topomap": {n: 0.0 for n in CHANNEL_NAMES},
606
+ "signal_quality": [0.0] * N_CHANNELS,
607
+ }
608
+
609
+ try:
610
+ clean = self.processor.preprocess(epoch)
611
+ bp = self.processor.band_power(clean)
612
+ topo = self.processor.compute_topomap(epoch)
613
+
614
+ # Signal quality: inverse of variance relative to expected range
615
+ peak2peak = (epoch.max(axis=1) - epoch.min(axis=1)) * 1e6 # µV
616
+ quality = [float(np.clip(1.0 - (pp - 10) / 90.0, 0.0, 1.0)) for pp in peak2peak]
617
+
618
+ # Raw traces for 4 selected channels (µV)
619
+ sel = [0, 4, 8, 12]
620
+ raw = (clean[sel, -50:] * 1e6).tolist() # last 200ms
621
+
622
+ return {
623
+ "channels": CHANNEL_NAMES,
624
+ "alpha_power": [float(v * 1e12) for v in bp["alpha"]],
625
+ "beta_power": [float(v * 1e12) for v in bp["beta"]],
626
+ "theta_power": [float(v * 1e12) for v in bp["theta"]],
627
+ "raw_samples": raw,
628
+ "topomap": {k: float(v * 1e12) for k, v in topo.items()},
629
+ "signal_quality": quality,
630
+ }
631
+ except Exception as e:
632
+ logger.warning(f"Live signal error: {e}")
633
+ return {}
634
+
635
+ # ── Session State ──────────────────────────────────────────────────────
636
+
637
+ def get_state(self) -> dict:
638
+ class_a_trials = [t.to_dict() for t in self.trials if t.class_label == self.config.class_a]
639
+ class_b_trials = [t.to_dict() for t in self.trials if t.class_label == self.config.class_b]
640
+
641
+ states = {}
642
+ for label, state in self.nf_engine.states.items():
643
+ states[label] = {
644
+ "n_trials": state.n_trials,
645
+ "convergence_score": round(state.convergence_score, 4),
646
+ "created_at": state.created_at,
647
+ }
648
+
649
+ return {
650
+ "phase": self.phase.value,
651
+ "config": {
652
+ "subject_id": self.config.subject_id,
653
+ "session_id": self.config.session_id,
654
+ "class_a": self.config.class_a,
655
+ "class_b": self.config.class_b,
656
+ "simulate": self.config.simulate,
657
+ "min_quality_for_model": self.config.min_quality_for_model,
658
+ },
659
+ "class_a_trials": class_a_trials,
660
+ "class_b_trials": class_b_trials,
661
+ "neural_states": states,
662
+ "classifier_trained": self.classifier.is_trained,
663
+ "board_connected": self.board.connected,
664
+ "total_trials": len(self.trials),
665
+ }
666
+
667
+ # ── WebSocket Broadcast ────────────────────────────────────────────────
668
+
669
+ def add_ws_client(self, ws: WebSocket):
670
+ self._ws_clients.append(ws)
671
+
672
+ def remove_ws_client(self, ws: WebSocket):
673
+ if ws in self._ws_clients:
674
+ self._ws_clients.remove(ws)
675
+
676
+ async def broadcast(self, data: dict):
677
+ dead = []
678
+ for ws in self._ws_clients:
679
+ try:
680
+ await ws.send_json(data)
681
+ except Exception:
682
+ dead.append(ws)
683
+ for ws in dead:
684
+ self.remove_ws_client(ws)
685
+
686
+ async def live_broadcast_loop(self):
687
+ """Continuously push live EEG data to all connected WS clients"""
688
+ while True:
689
+ await asyncio.sleep(0.1) # 10 Hz update
690
+ if self._ws_clients and self.board.connected:
691
+ live = self.get_live_signals()
692
+ if live:
693
+ live["type"] = "live_eeg"
694
+ live["timestamp"] = time.time()
695
+ await self.broadcast(live)
696
+
697
+ # ─── FastAPI App ──────────────────────────────────────────────────────────────
698
+
699
+ ROOT_DIR = Path(__file__).resolve().parent
700
+ SITE_HTML = ROOT_DIR / "site.html"
701
+
702
+ app = FastAPI(title="EEG Mental Imagery Backend", version="2.0.0")
703
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
704
+
705
+ session = SessionManager()
706
+
707
+
708
+ @app.get("/")
709
+ def serve_ui():
710
+ """Serve the project UI (site.html) from the same origin as the API / WebSocket."""
711
+ if not SITE_HTML.is_file():
712
+ return JSONResponse(
713
+ status_code=404,
714
+ content={"error": "site.html not found", "path": str(SITE_HTML)},
715
+ )
716
+ return FileResponse(SITE_HTML, media_type="text/html; charset=utf-8")
717
+
718
+ @app.on_event("startup")
719
+ async def startup():
720
+ asyncio.create_task(session.live_broadcast_loop())
721
+ logger.info("EEG Backend started")
722
+
723
+ # ── REST Endpoints ─────────────────────────────────────────────────────────
724
+
725
+ @app.get("/health")
726
+ def health():
727
+ return {"status": "ok", "brainflow": BRAINFLOW_AVAILABLE, "lsl": LSL_AVAILABLE,
728
+ "sklearn": SKLEARN_AVAILABLE, "scipy": SCIPY_AVAILABLE}
729
+
730
+ @app.post("/board/connect")
731
+ def connect_board(port: str = "/dev/ttyUSB0", simulate: bool = False):
732
+ session.config.port = port
733
+ session.config.simulate = simulate or not BRAINFLOW_AVAILABLE
734
+ return session.connect_board()
735
+
736
+ @app.post("/board/disconnect")
737
+ def disconnect_board():
738
+ session.disconnect_board()
739
+ return {"status": "disconnected"}
740
+
741
+ @app.post("/session/configure")
742
+ def configure_session(
743
+ subject_id: str = "S001",
744
+ class_a: str = "Apple",
745
+ class_b: str = "House",
746
+ min_quality: int = 4
747
+ ):
748
+ session.config.subject_id = subject_id
749
+ session.config.class_a = class_a
750
+ session.config.class_b = class_b
751
+ session.config.min_quality_for_model = min_quality
752
+ session.config.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
753
+ return {"status": "configured", "config": asdict(session.config)}
754
+
755
+ @app.post("/trial/start")
756
+ def start_trial(class_label: str):
757
+ trial = session.start_trial(class_label)
758
+ return trial.to_dict()
759
+
760
+ @app.post("/trial/end")
761
+ def end_trial(quality: int):
762
+ return session.end_trial(quality)
763
+
764
+ @app.get("/state")
765
+ def get_state():
766
+ return session.get_state()
767
+
768
+ @app.get("/signals/live")
769
+ def live_signals():
770
+ return session.get_live_signals()
771
+
772
+ @app.get("/channels/info")
773
+ def channel_info():
774
+ return {
775
+ "n_channels": N_CHANNELS,
776
+ "channel_names": CHANNEL_NAMES,
777
+ "positions": ELECTRODE_POSITIONS_16CH,
778
+ "sample_rate": SAMPLE_RATE,
779
+ }
780
+
781
+ @app.post("/lsl/marker")
782
+ def push_marker(marker: str):
783
+ session.lsl.push_marker(marker)
784
+ return {"pushed": marker, "timestamp": time.time()}
785
+
786
+ @app.get("/classifier/predict")
787
+ def predict_current():
788
+ epoch = session.board.get_epoch(EPOCH_DURATION)
789
+ if epoch is None:
790
+ return {"error": "no data"}
791
+ feats = session.processor.extract_features(epoch)
792
+ proba = session.classifier.predict_proba(feats)
793
+ nf_a = session.nf_engine.compute_similarity(feats, session.config.class_a)
794
+ nf_b = session.nf_engine.compute_similarity(feats, session.config.class_b)
795
+ return {
796
+ "probabilities": proba,
797
+ "nf_similarity": {session.config.class_a: nf_a, session.config.class_b: nf_b},
798
+ "timestamp": time.time(),
799
+ }
800
+
801
+ @app.post("/session/reset")
802
+ def reset_session():
803
+ session.trials.clear()
804
+ session.trial_counter = 0
805
+ session.nf_engine.states.clear()
806
+ session.classifier.is_trained = False
807
+ session.phase = SessionPhase.IDLE
808
+ return {"status": "reset"}
809
+
810
+ # ── WebSocket ──────────────────────────────────────────────────────────────
811
+
812
+ @app.websocket("/ws")
813
+ async def websocket_endpoint(ws: WebSocket):
814
+ await ws.accept()
815
+ session.add_ws_client(ws)
816
+ logger.info(f"WebSocket client connected. Total: {len(session._ws_clients)}")
817
+ try:
818
+ while True:
819
+ msg = await ws.receive_json()
820
+ msg_type = msg.get("type", "")
821
+
822
+ if msg_type == "ping":
823
+ await ws.send_json({"type": "pong", "timestamp": time.time()})
824
+
825
+ elif msg_type == "state":
826
+ await ws.send_json({"type": "state", **session.get_state()})
827
+
828
+ elif msg_type == "start_trial":
829
+ trial = session.start_trial(msg["class_label"])
830
+ await ws.send_json({"type": "trial_started", **trial.to_dict()})
831
+
832
+ elif msg_type == "end_trial":
833
+ result = session.end_trial(msg["quality"])
834
+ await ws.send_json({"type": "trial_ended", **result})
835
+ # Broadcast state update to all clients
836
+ await session.broadcast({"type": "state_update", **session.get_state()})
837
+
838
+ elif msg_type == "configure":
839
+ session.config.subject_id = msg.get("subject_id", session.config.subject_id)
840
+ session.config.class_a = msg.get("class_a", session.config.class_a)
841
+ session.config.class_b = msg.get("class_b", session.config.class_b)
842
+ mq = msg.get("min_quality")
843
+ if mq is not None:
844
+ session.config.min_quality_for_model = int(mq)
845
+ await ws.send_json({"type": "configured"})
846
+
847
+ elif msg_type == "connect_board":
848
+ session.config.simulate = msg.get("simulate", True)
849
+ if msg.get("port"):
850
+ session.config.port = str(msg["port"])
851
+ result = session.connect_board()
852
+ await ws.send_json({"type": "board_status", **result})
853
+
854
+ except WebSocketDisconnect:
855
+ session.remove_ws_client(ws)
856
+ logger.info(f"WebSocket client disconnected. Remaining: {len(session._ws_clients)}")
857
+
858
+ # ─── Entry Point ──────────────────────────────────────────────────────────────
859
+
860
+ if __name__ == "__main__":
861
+ uvicorn.run("backend:app", host="0.0.0.0", port=8765, reload=False, log_level="info")
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.29.0
3
+ numpy>=1.26.0
4
+ scipy>=1.12.0
5
+ scikit-learn>=1.4.0
6
+ brainflow>=5.12.0
7
+ pylsl>=1.16.0
8
+ websockets>=12.0
9
+ python-multipart>=0.0.9
10
+
site.html ADDED
@@ -0,0 +1,2235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Neural State Classifier · EEG Mental Imagery</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:wght@200;300;400;500&display=swap');
9
+
10
+ :root {
11
+ /* Semantic tokens (referenced throughout) */
12
+ --color-background-primary: #FAFAF8;
13
+ --color-background-secondary: #F3F2EE;
14
+ --color-background-tertiary: #EAE8E3;
15
+ --color-text-primary: #1C1B19;
16
+ --color-text-secondary: #4D4C47;
17
+ --color-text-tertiary: #8E8C86;
18
+ --color-border-tertiary: #DAD7D1;
19
+ --color-border-secondary: #C5C2BB;
20
+ --font-sans: 'DM Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
21
+ --border-radius-lg: 10px;
22
+ --border-radius-md: 8px;
23
+ --shadow-sm: 0 1px 2px rgba(24, 22, 18, 0.06);
24
+ --shadow-md: 0 8px 32px rgba(24, 22, 18, 0.08);
25
+ --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
26
+
27
+ --bg: var(--color-background-primary);
28
+ --bg2: var(--color-background-secondary);
29
+ --bg3: var(--color-background-tertiary);
30
+ --txt: var(--color-text-primary);
31
+ --txt2: var(--color-text-secondary);
32
+ --txt3: var(--color-text-tertiary);
33
+ --border: var(--color-border-tertiary);
34
+ --border2: var(--color-border-secondary);
35
+ --accent: #185FA5;
36
+ --accent-light: #E6F1FB;
37
+ --teal: #0F6E56;
38
+ --teal-light: #E1F5EE;
39
+ --amber: #854F0B;
40
+ --amber-light: #FAEEDA;
41
+ --coral: #993C1D;
42
+ --coral-light: #FAECE7;
43
+ --purple: #3C3489;
44
+ --purple-light: #EEEDFE;
45
+ --mono: 'DM Mono', ui-monospace, 'Cascadia Mono', monospace;
46
+ --sans: var(--font-sans);
47
+ }
48
+
49
+ * { box-sizing: border-box; margin: 0; padding: 0; }
50
+
51
+ html {
52
+ height: 100%;
53
+ -webkit-text-size-adjust: 100%;
54
+ }
55
+
56
+ body {
57
+ font-family: var(--sans);
58
+ min-height: 100%;
59
+ background: linear-gradient(165deg, var(--color-background-secondary) 0%, var(--color-background-primary) 45%, #EFECE6 100%);
60
+ background-attachment: fixed;
61
+ color: var(--txt);
62
+ font-size: 13px;
63
+ line-height: 1.45;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+
67
+ ::selection {
68
+ background: var(--accent-light);
69
+ color: var(--txt);
70
+ }
71
+
72
+ .app {
73
+ display: grid;
74
+ grid-template-columns: minmax(180px, 220px) 1fr;
75
+ grid-template-rows: auto 1fr;
76
+ min-height: min(720px, 100vh);
77
+ max-width: 1400px;
78
+ margin: 12px auto;
79
+ padding: 0 12px 12px;
80
+ border: 0.5px solid var(--border);
81
+ border-radius: var(--border-radius-lg);
82
+ overflow: hidden;
83
+ background: var(--bg);
84
+ box-shadow: var(--shadow-md);
85
+ }
86
+
87
+ /* HEADER */
88
+ .header {
89
+ grid-column: 1 / -1;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: space-between;
93
+ padding: 12px 20px;
94
+ border-bottom: 0.5px solid var(--border);
95
+ background: var(--bg);
96
+ }
97
+
98
+ .header-left {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 12px;
102
+ }
103
+
104
+ .logo-mark {
105
+ width: 28px;
106
+ height: 28px;
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ }
111
+
112
+ .header-title {
113
+ font-size: 12px;
114
+ font-weight: 500;
115
+ letter-spacing: 0.08em;
116
+ text-transform: uppercase;
117
+ color: var(--txt2);
118
+ }
119
+
120
+ .header-sub {
121
+ font-size: 11px;
122
+ color: var(--txt3);
123
+ font-family: var(--mono);
124
+ margin-top: 1px;
125
+ }
126
+
127
+ .status-row {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 16px;
131
+ }
132
+
133
+ .status-pill {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 5px;
137
+ font-size: 11px;
138
+ font-family: var(--mono);
139
+ color: var(--txt3);
140
+ }
141
+
142
+ .dot {
143
+ width: 6px;
144
+ height: 6px;
145
+ border-radius: 50%;
146
+ background: var(--color-border-secondary);
147
+ }
148
+
149
+ .dot.active {
150
+ background: #1D9E75;
151
+ animation: pulse 2s ease-in-out infinite;
152
+ }
153
+
154
+ .dot.warn {
155
+ background: #BA7517;
156
+ }
157
+
158
+ @keyframes pulse {
159
+ 0%, 100% { opacity: 1; }
160
+ 50% { opacity: 0.4; }
161
+ }
162
+
163
+ /* SIDEBAR */
164
+ .sidebar {
165
+ border-right: 0.5px solid var(--border);
166
+ background: var(--bg2);
167
+ padding: 0;
168
+ display: flex;
169
+ flex-direction: column;
170
+ min-height: 0;
171
+ }
172
+
173
+ .sidebar-section {
174
+ padding: 14px 14px 8px;
175
+ border-bottom: 0.5px solid var(--border);
176
+ }
177
+
178
+ .sidebar-label {
179
+ font-size: 10px;
180
+ letter-spacing: 0.1em;
181
+ text-transform: uppercase;
182
+ color: var(--txt3);
183
+ font-family: var(--mono);
184
+ margin-bottom: 8px;
185
+ }
186
+
187
+ .phase-item {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 8px;
191
+ padding: 6px 8px;
192
+ border-radius: 6px;
193
+ cursor: pointer;
194
+ margin-bottom: 2px;
195
+ transition: background 0.15s;
196
+ }
197
+
198
+ .phase-item:hover { background: var(--bg3); }
199
+ .phase-item.active { background: var(--accent-light); }
200
+
201
+ .phase-num {
202
+ width: 18px;
203
+ height: 18px;
204
+ border-radius: 4px;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ font-size: 10px;
209
+ font-family: var(--mono);
210
+ font-weight: 500;
211
+ background: var(--border);
212
+ color: var(--txt2);
213
+ flex-shrink: 0;
214
+ }
215
+
216
+ .phase-item.active .phase-num {
217
+ background: var(--accent);
218
+ color: white;
219
+ }
220
+
221
+ .phase-item.done .phase-num {
222
+ background: #1D9E75;
223
+ color: white;
224
+ }
225
+
226
+ .phase-text {
227
+ font-size: 11px;
228
+ color: var(--txt2);
229
+ line-height: 1.3;
230
+ }
231
+
232
+ .phase-item.active .phase-text { color: var(--accent); font-weight: 500; }
233
+
234
+ .class-pair {
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 4px;
238
+ margin-bottom: 8px;
239
+ }
240
+
241
+ .class-tag {
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: space-between;
245
+ padding: 5px 8px;
246
+ border-radius: 6px;
247
+ border: 0.5px solid var(--border2);
248
+ background: var(--bg);
249
+ cursor: pointer;
250
+ }
251
+
252
+ .class-tag.a { border-color: #B5D4F4; background: #E6F1FB; }
253
+ .class-tag.b { border-color: #9FE1CB; background: #E1F5EE; }
254
+
255
+ .class-name {
256
+ font-size: 11px;
257
+ font-weight: 500;
258
+ color: var(--txt);
259
+ }
260
+
261
+ .class-tag.a .class-name { color: #0C447C; }
262
+ .class-tag.b .class-name { color: #085041; }
263
+
264
+ .class-trials {
265
+ font-size: 10px;
266
+ font-family: var(--mono);
267
+ color: var(--txt3);
268
+ }
269
+
270
+ .class-tag.a .class-trials { color: #185FA5; }
271
+ .class-tag.b .class-trials { color: #0F6E56; }
272
+
273
+ .metric-mini {
274
+ display: flex;
275
+ flex-direction: column;
276
+ padding: 6px 8px;
277
+ border-radius: 6px;
278
+ background: var(--bg);
279
+ border: 0.5px solid var(--border);
280
+ margin-bottom: 4px;
281
+ }
282
+
283
+ .metric-mini-label { font-size: 10px; color: var(--txt3); font-family: var(--mono); }
284
+ .metric-mini-value { font-size: 15px; font-weight: 500; color: var(--txt); margin-top: 1px; }
285
+
286
+ .lib-entry {
287
+ display: flex;
288
+ align-items: center;
289
+ gap: 6px;
290
+ padding: 4px 6px;
291
+ border-radius: 4px;
292
+ margin-bottom: 2px;
293
+ cursor: pointer;
294
+ }
295
+
296
+ .lib-entry:hover { background: var(--bg3); }
297
+
298
+ .lib-dot {
299
+ width: 6px;
300
+ height: 6px;
301
+ border-radius: 50%;
302
+ flex-shrink: 0;
303
+ }
304
+
305
+ .lib-text { font-size: 11px; color: var(--txt2); }
306
+ .lib-score { font-size: 10px; font-family: var(--mono); color: var(--txt3); margin-left: auto; }
307
+
308
+ /* MAIN CONTENT */
309
+ .main {
310
+ display: flex;
311
+ flex-direction: column;
312
+ overflow: hidden;
313
+ }
314
+
315
+ /* TAB BAR */
316
+ .tabbar {
317
+ display: flex;
318
+ border-bottom: 0.5px solid var(--border);
319
+ background: var(--bg);
320
+ padding: 0 16px;
321
+ gap: 0;
322
+ }
323
+
324
+ .tab {
325
+ padding: 10px 14px;
326
+ font-size: 12px;
327
+ color: var(--txt3);
328
+ cursor: pointer;
329
+ border-bottom: 2px solid transparent;
330
+ margin-bottom: -0.5px;
331
+ transition: color 0.15s;
332
+ white-space: nowrap;
333
+ }
334
+
335
+ .tab:hover { color: var(--txt2); }
336
+ .tab.active { color: var(--txt); border-bottom-color: var(--txt); font-weight: 500; }
337
+
338
+ /* PANELS */
339
+ .panel { display: none; flex: 1; overflow-y: auto; }
340
+ .panel.active { display: flex; flex-direction: column; }
341
+
342
+ /* ACQUISITION PANEL */
343
+ .acq-layout {
344
+ display: grid;
345
+ grid-template-columns: 1fr 1fr;
346
+ gap: 12px;
347
+ padding: 16px;
348
+ flex: 1;
349
+ }
350
+
351
+ .card {
352
+ border: 0.5px solid var(--border);
353
+ border-radius: var(--border-radius-lg);
354
+ background: var(--bg);
355
+ overflow: hidden;
356
+ }
357
+
358
+ .card-header {
359
+ display: flex;
360
+ align-items: center;
361
+ justify-content: space-between;
362
+ padding: 10px 14px;
363
+ border-bottom: 0.5px solid var(--border);
364
+ background: var(--bg2);
365
+ }
366
+
367
+ .card-title {
368
+ font-size: 11px;
369
+ font-weight: 500;
370
+ text-transform: uppercase;
371
+ letter-spacing: 0.07em;
372
+ color: var(--txt2);
373
+ font-family: var(--mono);
374
+ }
375
+
376
+ .card-badge {
377
+ font-size: 10px;
378
+ font-family: var(--mono);
379
+ padding: 2px 6px;
380
+ border-radius: 4px;
381
+ background: var(--accent-light);
382
+ color: var(--accent);
383
+ }
384
+
385
+ .card-body { padding: 14px; }
386
+
387
+ /* EEG topography */
388
+ .topo-container {
389
+ display: flex;
390
+ justify-content: center;
391
+ padding: 8px 0;
392
+ }
393
+
394
+ /* Trial timeline */
395
+ .trial-row {
396
+ display: grid;
397
+ grid-template-columns: 28px 1fr auto auto;
398
+ align-items: center;
399
+ gap: 8px;
400
+ padding: 6px 0;
401
+ border-bottom: 0.5px solid var(--border);
402
+ }
403
+
404
+ .trial-row:last-child { border-bottom: none; }
405
+
406
+ .trial-num {
407
+ font-size: 11px;
408
+ font-family: var(--mono);
409
+ color: var(--txt3);
410
+ text-align: right;
411
+ }
412
+
413
+ .trial-bar-wrap {
414
+ position: relative;
415
+ height: 8px;
416
+ border-radius: 4px;
417
+ background: var(--bg3);
418
+ overflow: hidden;
419
+ }
420
+
421
+ .trial-bar {
422
+ height: 100%;
423
+ border-radius: 4px;
424
+ transition: width 0.4s ease;
425
+ }
426
+
427
+ .trial-score {
428
+ font-size: 11px;
429
+ font-family: var(--mono);
430
+ font-weight: 500;
431
+ min-width: 16px;
432
+ text-align: center;
433
+ }
434
+
435
+ .trial-badge {
436
+ font-size: 9px;
437
+ font-family: var(--mono);
438
+ padding: 1px 5px;
439
+ border-radius: 3px;
440
+ min-width: 28px;
441
+ text-align: center;
442
+ }
443
+
444
+ .trial-badge.keep { background: #E1F5EE; color: #085041; }
445
+ .trial-badge.drop { background: #F1EFE8; color: #5F5E5A; }
446
+
447
+ /* Rating stars */
448
+ .rating-row {
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: space-between;
452
+ margin: 10px 0;
453
+ }
454
+
455
+ .star-group {
456
+ display: flex;
457
+ gap: 6px;
458
+ }
459
+
460
+ .star {
461
+ width: 32px;
462
+ height: 32px;
463
+ display: flex;
464
+ align-items: center;
465
+ justify-content: center;
466
+ border-radius: 6px;
467
+ border: 0.5px solid var(--border2);
468
+ cursor: pointer;
469
+ font-size: 16px;
470
+ transition: all 0.15s;
471
+ background: var(--bg);
472
+ }
473
+
474
+ .star:hover, .star.sel {
475
+ background: #FAEEDA;
476
+ border-color: #FAC775;
477
+ transform: scale(1.05);
478
+ }
479
+
480
+ .current-trial-info {
481
+ background: var(--bg2);
482
+ border-radius: 8px;
483
+ padding: 12px;
484
+ margin-bottom: 12px;
485
+ }
486
+
487
+ .trial-class-label {
488
+ font-size: 10px;
489
+ font-family: var(--mono);
490
+ color: var(--txt3);
491
+ margin-bottom: 4px;
492
+ }
493
+
494
+ .trial-object {
495
+ font-size: 20px;
496
+ font-weight: 300;
497
+ color: var(--txt);
498
+ letter-spacing: 0.02em;
499
+ }
500
+
501
+ .trial-timer {
502
+ font-size: 11px;
503
+ font-family: var(--mono);
504
+ color: var(--txt3);
505
+ margin-top: 4px;
506
+ }
507
+
508
+ /* Progress bar */
509
+ .progress-wrap {
510
+ height: 3px;
511
+ background: var(--bg3);
512
+ border-radius: 2px;
513
+ margin: 10px 0;
514
+ overflow: hidden;
515
+ }
516
+
517
+ .progress-bar {
518
+ height: 100%;
519
+ background: var(--accent);
520
+ border-radius: 2px;
521
+ transition: width 0.3s;
522
+ }
523
+
524
+ /* Neurofeedback panel */
525
+ .nf-layout {
526
+ display: grid;
527
+ grid-template-columns: 1fr 1fr;
528
+ gap: 12px;
529
+ padding: 16px;
530
+ }
531
+
532
+ .nf-full { grid-column: 1 / -1; }
533
+
534
+ .convergence-track {
535
+ position: relative;
536
+ height: 60px;
537
+ border: 0.5px solid var(--border);
538
+ border-radius: 8px;
539
+ overflow: hidden;
540
+ background: var(--bg2);
541
+ }
542
+
543
+ .conv-fill {
544
+ position: absolute;
545
+ top: 0; left: 0; bottom: 0;
546
+ border-radius: 8px 0 0 8px;
547
+ transition: width 1s ease;
548
+ opacity: 0.6;
549
+ }
550
+
551
+ .conv-label {
552
+ position: absolute;
553
+ top: 50%;
554
+ transform: translateY(-50%);
555
+ right: 10px;
556
+ font-size: 11px;
557
+ font-family: var(--mono);
558
+ color: var(--txt2);
559
+ z-index: 2;
560
+ }
561
+
562
+ .conv-pct {
563
+ position: absolute;
564
+ top: 50%;
565
+ transform: translateY(-50%);
566
+ left: 10px;
567
+ font-size: 13px;
568
+ font-weight: 500;
569
+ font-family: var(--mono);
570
+ color: var(--txt);
571
+ z-index: 2;
572
+ }
573
+
574
+ .audio-row {
575
+ display: flex;
576
+ align-items: center;
577
+ gap: 8px;
578
+ padding: 8px;
579
+ border-radius: 6px;
580
+ background: var(--bg2);
581
+ margin-bottom: 6px;
582
+ }
583
+
584
+ .audio-icon {
585
+ width: 24px;
586
+ height: 24px;
587
+ border-radius: 5px;
588
+ display: flex;
589
+ align-items: center;
590
+ justify-content: center;
591
+ flex-shrink: 0;
592
+ }
593
+
594
+ .audio-icon.pos { background: #E1F5EE; }
595
+ .audio-icon.neg { background: #FAECE7; }
596
+
597
+ .audio-text {
598
+ flex: 1;
599
+ font-size: 11px;
600
+ color: var(--txt2);
601
+ }
602
+
603
+ .audio-freq {
604
+ font-size: 10px;
605
+ font-family: var(--mono);
606
+ color: var(--txt3);
607
+ }
608
+
609
+ /* Library panel */
610
+ .lib-layout {
611
+ padding: 16px;
612
+ display: flex;
613
+ flex-direction: column;
614
+ gap: 12px;
615
+ }
616
+
617
+ .lib-grid {
618
+ display: grid;
619
+ grid-template-columns: repeat(3, 1fr);
620
+ gap: 8px;
621
+ }
622
+
623
+ .lib-card {
624
+ border: 0.5px solid var(--border);
625
+ border-radius: var(--border-radius-lg);
626
+ padding: 12px;
627
+ background: var(--bg);
628
+ cursor: pointer;
629
+ transition: border-color 0.15s, background 0.15s;
630
+ }
631
+
632
+ .lib-card:hover { background: var(--bg2); border-color: var(--border2); }
633
+
634
+ .lib-card-name {
635
+ font-size: 13px;
636
+ font-weight: 500;
637
+ margin-bottom: 6px;
638
+ color: var(--txt);
639
+ }
640
+
641
+ .lib-card-meta {
642
+ font-size: 10px;
643
+ font-family: var(--mono);
644
+ color: var(--txt3);
645
+ margin-bottom: 8px;
646
+ line-height: 1.6;
647
+ }
648
+
649
+ .lib-card-bar {
650
+ height: 3px;
651
+ border-radius: 2px;
652
+ background: var(--bg3);
653
+ overflow: hidden;
654
+ }
655
+
656
+ .lib-card-fill {
657
+ height: 100%;
658
+ border-radius: 2px;
659
+ }
660
+
661
+ .subject-row {
662
+ display: flex;
663
+ align-items: center;
664
+ gap: 8px;
665
+ padding: 8px 10px;
666
+ border-radius: 6px;
667
+ background: var(--bg2);
668
+ margin-bottom: 4px;
669
+ }
670
+
671
+ .subject-avatar {
672
+ width: 24px;
673
+ height: 24px;
674
+ border-radius: 50%;
675
+ display: flex;
676
+ align-items: center;
677
+ justify-content: center;
678
+ font-size: 10px;
679
+ font-weight: 500;
680
+ flex-shrink: 0;
681
+ }
682
+
683
+ .subject-info { flex: 1; }
684
+ .subject-name { font-size: 12px; font-weight: 500; color: var(--txt); }
685
+ .subject-meta { font-size: 10px; font-family: var(--mono); color: var(--txt3); }
686
+
687
+ .subject-acc {
688
+ font-size: 12px;
689
+ font-family: var(--mono);
690
+ font-weight: 500;
691
+ }
692
+
693
+ /* Action buttons */
694
+ .btn {
695
+ display: inline-flex;
696
+ align-items: center;
697
+ gap: 6px;
698
+ padding: 7px 14px;
699
+ border-radius: 6px;
700
+ border: 0.5px solid var(--border2);
701
+ background: var(--bg);
702
+ color: var(--txt);
703
+ font-size: 12px;
704
+ cursor: pointer;
705
+ transition: background 0.15s;
706
+ font-family: var(--sans);
707
+ }
708
+
709
+ .btn:hover { background: var(--bg2); }
710
+ .btn.primary { background: var(--accent); color: white; border-color: var(--accent); }
711
+ .btn.primary:hover { opacity: 0.9; }
712
+
713
+ /* Bottom actions */
714
+ .panel-footer {
715
+ display: flex;
716
+ align-items: center;
717
+ justify-content: space-between;
718
+ padding: 10px 16px;
719
+ border-top: 0.5px solid var(--border);
720
+ background: var(--bg2);
721
+ }
722
+
723
+ /* Waveform */
724
+ .waveform-wrap {
725
+ height: 80px;
726
+ position: relative;
727
+ overflow: hidden;
728
+ }
729
+
730
+ canvas.waveform {
731
+ width: 100%;
732
+ height: 80px;
733
+ }
734
+
735
+ /* General info */
736
+ .info-row {
737
+ display: flex;
738
+ align-items: baseline;
739
+ justify-content: space-between;
740
+ padding: 5px 0;
741
+ border-bottom: 0.5px solid var(--border);
742
+ font-size: 12px;
743
+ }
744
+
745
+ .info-row:last-child { border-bottom: none; }
746
+ .info-key { color: var(--txt3); font-family: var(--mono); }
747
+ .info-val { color: var(--txt); font-weight: 500; }
748
+
749
+ .section-title {
750
+ font-size: 11px;
751
+ font-weight: 500;
752
+ text-transform: uppercase;
753
+ letter-spacing: 0.08em;
754
+ color: var(--txt3);
755
+ font-family: var(--mono);
756
+ margin-bottom: 8px;
757
+ }
758
+
759
+ /* ─── Forms & inputs ─── */
760
+ input[type="text"],
761
+ input[type="number"],
762
+ select,
763
+ textarea {
764
+ font-family: var(--sans);
765
+ font-size: 12px;
766
+ padding: 7px 10px;
767
+ border-radius: 6px;
768
+ border: 0.5px solid var(--border2);
769
+ background: var(--bg);
770
+ color: var(--txt);
771
+ width: 100%;
772
+ max-width: 100%;
773
+ transition: border-color 0.15s, box-shadow 0.15s;
774
+ }
775
+
776
+ input:focus-visible,
777
+ select:focus-visible,
778
+ textarea:focus-visible {
779
+ outline: none;
780
+ border-color: var(--accent);
781
+ box-shadow: var(--focus-ring);
782
+ }
783
+
784
+ label.field-label {
785
+ display: block;
786
+ font-size: 10px;
787
+ font-family: var(--mono);
788
+ letter-spacing: 0.06em;
789
+ text-transform: uppercase;
790
+ color: var(--txt3);
791
+ margin-bottom: 4px;
792
+ }
793
+
794
+ .field-group {
795
+ margin-bottom: 10px;
796
+ }
797
+
798
+ /* ─── Sidebar scroll & flex ─── */
799
+ .sidebar-section:last-child {
800
+ flex: 1;
801
+ min-height: 0;
802
+ overflow-y: auto;
803
+ scrollbar-width: thin;
804
+ scrollbar-color: var(--border2) transparent;
805
+ }
806
+
807
+ .sidebar-section:last-child::-webkit-scrollbar {
808
+ width: 6px;
809
+ }
810
+
811
+ .sidebar-section:last-child::-webkit-scrollbar-thumb {
812
+ background: var(--border2);
813
+ border-radius: 4px;
814
+ }
815
+
816
+ /* ─── Panels scroll ─── */
817
+ .panel {
818
+ scrollbar-width: thin;
819
+ scrollbar-color: var(--border2) transparent;
820
+ }
821
+
822
+ .panel::-webkit-scrollbar {
823
+ width: 8px;
824
+ }
825
+
826
+ .panel::-webkit-scrollbar-thumb {
827
+ background: var(--border2);
828
+ border-radius: 4px;
829
+ }
830
+
831
+ /* ─── Buttons ─── */
832
+ button.btn {
833
+ appearance: none;
834
+ border: 0.5px solid var(--border2);
835
+ }
836
+
837
+ button.btn:focus-visible {
838
+ outline: none;
839
+ box-shadow: var(--focus-ring);
840
+ }
841
+
842
+ button.btn:disabled {
843
+ opacity: 0.45;
844
+ cursor: not-allowed;
845
+ }
846
+
847
+ .btn.danger {
848
+ border-color: #E8B4A8;
849
+ background: var(--coral-light);
850
+ color: var(--coral);
851
+ }
852
+
853
+ .btn.danger:hover {
854
+ background: #F5DCD4;
855
+ }
856
+
857
+ .btn.ghost {
858
+ background: transparent;
859
+ border-color: transparent;
860
+ }
861
+
862
+ .btn.ghost:hover {
863
+ background: var(--bg2);
864
+ border-color: var(--border);
865
+ }
866
+
867
+ .btn.sm {
868
+ padding: 5px 10px;
869
+ font-size: 11px;
870
+ }
871
+
872
+ /* ─── Tabs accessibility ─── */
873
+ .tab:focus-visible {
874
+ outline: none;
875
+ box-shadow: inset 0 -2px 0 var(--accent);
876
+ color: var(--txt);
877
+ }
878
+
879
+ .phase-item:focus-visible {
880
+ outline: none;
881
+ box-shadow: 0 0 0 2px var(--accent-light);
882
+ }
883
+
884
+ /* ─── Cards & canvases ─── */
885
+ .card-body canvas {
886
+ display: block;
887
+ max-width: 100%;
888
+ height: auto;
889
+ }
890
+
891
+ #distCanvas {
892
+ width: 100%;
893
+ max-width: 100%;
894
+ height: auto;
895
+ min-height: 80px;
896
+ }
897
+
898
+ /* ─── Links ─── */
899
+ a {
900
+ color: var(--accent);
901
+ text-decoration: none;
902
+ }
903
+
904
+ a:hover {
905
+ text-decoration: underline;
906
+ }
907
+
908
+ /* ─── Utility ─── */
909
+ .sr-only {
910
+ position: absolute;
911
+ width: 1px;
912
+ height: 1px;
913
+ padding: 0;
914
+ margin: -1px;
915
+ overflow: hidden;
916
+ clip: rect(0, 0, 0, 0);
917
+ white-space: nowrap;
918
+ border: 0;
919
+ }
920
+
921
+ .muted { color: var(--txt3); }
922
+ .mono { font-family: var(--mono); }
923
+ .stack { display: flex; flex-direction: column; gap: 8px; }
924
+
925
+ /* ─── Responsive ─── */
926
+ @media (max-width: 900px) {
927
+ .app {
928
+ grid-template-columns: 1fr;
929
+ grid-template-rows: auto auto 1fr;
930
+ margin: 0;
931
+ padding: 0;
932
+ border-radius: 0;
933
+ border-left: none;
934
+ border-right: none;
935
+ min-height: 100vh;
936
+ max-width: none;
937
+ }
938
+
939
+ .header {
940
+ flex-wrap: wrap;
941
+ gap: 10px;
942
+ }
943
+
944
+ .sidebar {
945
+ border-right: none;
946
+ border-bottom: 0.5px solid var(--border);
947
+ flex-direction: row;
948
+ flex-wrap: wrap;
949
+ max-height: none;
950
+ }
951
+
952
+ .sidebar-section {
953
+ flex: 1 1 140px;
954
+ border-bottom: 0.5px solid var(--border);
955
+ }
956
+
957
+ .sidebar-section:last-child {
958
+ flex: 1 1 100%;
959
+ max-height: 160px;
960
+ }
961
+
962
+ .acq-layout,
963
+ .nf-layout {
964
+ grid-template-columns: 1fr;
965
+ }
966
+
967
+ .lib-grid {
968
+ grid-template-columns: repeat(2, 1fr);
969
+ }
970
+
971
+ .tabbar {
972
+ overflow-x: auto;
973
+ -webkit-overflow-scrolling: touch;
974
+ }
975
+ }
976
+
977
+ @media (max-width: 520px) {
978
+ .lib-grid {
979
+ grid-template-columns: 1fr;
980
+ }
981
+
982
+ .status-row {
983
+ flex-wrap: wrap;
984
+ gap: 8px;
985
+ }
986
+
987
+ .star {
988
+ width: 28px;
989
+ height: 28px;
990
+ font-size: 14px;
991
+ }
992
+ }
993
+
994
+ @media (prefers-reduced-motion: reduce) {
995
+ *,
996
+ *::before,
997
+ *::after {
998
+ animation-duration: 0.01ms !important;
999
+ animation-iteration-count: 1 !important;
1000
+ transition-duration: 0.01ms !important;
1001
+ }
1002
+
1003
+ .dot.active {
1004
+ animation: none;
1005
+ }
1006
+ }
1007
+
1008
+ /* ─── Print ─── */
1009
+ @media print {
1010
+ body {
1011
+ background: white;
1012
+ }
1013
+
1014
+ .app {
1015
+ box-shadow: none;
1016
+ margin: 0;
1017
+ max-width: none;
1018
+ min-height: auto;
1019
+ }
1020
+
1021
+ .panel-footer,
1022
+ .tabbar .tab:not(.active),
1023
+ .btn {
1024
+ display: none !important;
1025
+ }
1026
+
1027
+ .panel {
1028
+ display: flex !important;
1029
+ overflow: visible;
1030
+ }
1031
+ }
1032
+
1033
+ </style>
1034
+ </head>
1035
+ <body>
1036
+
1037
+ <h2 class="sr-only">EEG mental-imagery classification paradigm — acquisition, neurofeedback, and object library dashboard</h2>
1038
+
1039
+ <div class="app">
1040
+
1041
+ <!-- HEADER -->
1042
+ <div class="header">
1043
+ <div class="header-left">
1044
+ <div class="logo-mark">
1045
+ <svg width="28" height="28" viewBox="0 0 28 28" fill="none">
1046
+ <circle cx="14" cy="14" r="13" stroke="var(--border2)" stroke-width="0.5"/>
1047
+ <circle cx="14" cy="14" r="9" stroke="var(--accent)" stroke-width="0.5" stroke-dasharray="2 2"/>
1048
+ <circle cx="14" cy="14" r="3" fill="var(--accent)"/>
1049
+ <circle cx="14" cy="6" r="1.5" fill="var(--txt3)"/>
1050
+ <circle cx="22" cy="10" r="1.5" fill="var(--txt3)"/>
1051
+ <circle cx="22" cy="18" r="1.5" fill="var(--txt3)"/>
1052
+ <circle cx="14" cy="22" r="1.5" fill="var(--txt3)"/>
1053
+ <circle cx="6" cy="18" r="1.5" fill="var(--txt3)"/>
1054
+ <circle cx="6" cy="10" r="1.5" fill="var(--txt3)"/>
1055
+ </svg>
1056
+ </div>
1057
+ <div>
1058
+ <div class="header-title">Neural State Classifier</div>
1059
+ <div class="header-sub">EEG Mental Imagery Paradigm · 16ch Parieto-Occipital</div>
1060
+ </div>
1061
+ </div>
1062
+ <div class="status-row">
1063
+ <div class="status-pill"><span class="dot" id="dot-phase"></span> <span id="txt-phase">IDLE</span></div>
1064
+ <div class="status-pill"><span class="dot" id="dot-session"></span> <span id="txt-session">— · —</span></div>
1065
+ <div class="status-pill"><span class="dot" id="dot-board"></span> <span id="txt-hz">250 Hz · 16 ch</span></div>
1066
+ </div>
1067
+ </div>
1068
+
1069
+ <!-- SIDEBAR -->
1070
+ <div class="sidebar">
1071
+
1072
+ <div class="sidebar-section">
1073
+ <div class="sidebar-label">Backend</div>
1074
+ <div id="backend-status" style="font-size:10px;font-family:var(--mono);color:var(--txt3);line-height:1.5;margin-bottom:10px">REST: … · WS: … · Board: …</div>
1075
+ <label class="field-label" for="cfg-subject">Subject ID</label>
1076
+ <input id="cfg-subject" type="text" value="S001" autocomplete="off">
1077
+ <label class="field-label" for="cfg-class-a">Class A label</label>
1078
+ <input id="cfg-class-a" type="text" value="Chair" autocomplete="off">
1079
+ <label class="field-label" for="cfg-class-b">Class B label</label>
1080
+ <input id="cfg-class-b" type="text" value="Spiral" autocomplete="off">
1081
+ <label class="field-label" for="cfg-min-q">Min quality (1–5)</label>
1082
+ <input id="cfg-min-q" type="number" min="1" max="5" value="4">
1083
+ <label class="field-label" for="cfg-port">Serial port (hardware)</label>
1084
+ <input id="cfg-port" type="text" placeholder="/dev/ttyUSB0 or COM3" autocomplete="off">
1085
+ <div style="display:flex;flex-direction:column;gap:6px;margin-top:10px">
1086
+ <button type="button" class="btn primary sm" onclick="applyConfiguration()">Apply configuration</button>
1087
+ <button type="button" class="btn sm" onclick="connectBoard(true)">Connect · simulate</button>
1088
+ <button type="button" class="btn sm" onclick="connectBoard(false)">Connect · hardware</button>
1089
+ <button type="button" class="btn sm" onclick="disconnectBoard()">Disconnect board</button>
1090
+ <button type="button" class="btn ghost sm" onclick="resetSession()">Reset session</button>
1091
+ </div>
1092
+ </div>
1093
+
1094
+ <div class="sidebar-section">
1095
+ <div class="sidebar-label">Phases</div>
1096
+ <div class="phase-item done" onclick="showTab('acq')">
1097
+ <div class="phase-num">1</div>
1098
+ <div class="phase-text">Acquisition + self-rating</div>
1099
+ </div>
1100
+ <div class="phase-item done" onclick="showTab('acq')">
1101
+ <div class="phase-num">2</div>
1102
+ <div class="phase-text">Filtering · stable state</div>
1103
+ </div>
1104
+ <div class="phase-item active" onclick="showTab('nf')">
1105
+ <div class="phase-num">3</div>
1106
+ <div class="phase-text">Real-time neurofeedback</div>
1107
+ </div>
1108
+ <div class="phase-item" onclick="showTab('lib')">
1109
+ <div class="phase-num">4</div>
1110
+ <div class="phase-text">Multi-class object library</div>
1111
+ </div>
1112
+ <div class="phase-item" onclick="showTab('lib')">
1113
+ <div class="phase-num">5</div>
1114
+ <div class="phase-text">Cross-subject generalization</div>
1115
+ </div>
1116
+ </div>
1117
+
1118
+ <div class="sidebar-section">
1119
+ <div class="sidebar-label">Current session</div>
1120
+ <div class="class-pair">
1121
+ <div class="class-tag a">
1122
+ <span class="class-name" id="ui-class-a-name">Chair</span>
1123
+ <span class="class-trials" id="ui-class-a-n">0 trials</span>
1124
+ </div>
1125
+ <div class="class-tag b">
1126
+ <span class="class-name" id="ui-class-b-name">Spiral</span>
1127
+ <span class="class-trials" id="ui-class-b-n">0 trials</span>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ <div class="sidebar-section">
1133
+ <div class="sidebar-label">Metrics</div>
1134
+ <div class="metric-mini">
1135
+ <span class="metric-mini-label">Convergence (mean)</span>
1136
+ <span class="metric-mini-value" id="metric-conv">—</span>
1137
+ </div>
1138
+ <div class="metric-mini">
1139
+ <span class="metric-mini-label">Trials kept</span>
1140
+ <span class="metric-mini-value" id="metric-kept">—</span>
1141
+ </div>
1142
+ <div class="metric-mini">
1143
+ <span class="metric-mini-label">Mean score</span>
1144
+ <span class="metric-mini-value" id="metric-mean">—</span>
1145
+ </div>
1146
+ <div class="metric-mini">
1147
+ <span class="metric-mini-label">Classifier</span>
1148
+ <span class="metric-mini-value" id="metric-lda">—</span>
1149
+ </div>
1150
+ </div>
1151
+
1152
+ <div class="sidebar-section" style="flex:1; overflow-y:auto;">
1153
+ <div class="sidebar-label">Neural states</div>
1154
+ <div id="sidebar-library-list"></div>
1155
+ </div>
1156
+
1157
+ </div><!-- /sidebar -->
1158
+
1159
+ <!-- MAIN -->
1160
+ <div class="main">
1161
+
1162
+ <!-- TABBAR -->
1163
+ <div class="tabbar">
1164
+ <div class="tab active" id="tab-acq" onclick="showTab('acq')">Acquisition</div>
1165
+ <div class="tab" id="tab-nf" onclick="showTab('nf')">Neurofeedback</div>
1166
+ <div class="tab" id="tab-model" onclick="showTab('model')">Model</div>
1167
+ <div class="tab" id="tab-lib" onclick="showTab('lib')">Library</div>
1168
+ </div>
1169
+
1170
+ <!-- ======== PANEL: ACQUISITION ======== -->
1171
+ <div class="panel active" id="panel-acq">
1172
+ <div class="acq-layout">
1173
+
1174
+ <!-- Current trial -->
1175
+ <div class="card">
1176
+ <div class="card-header">
1177
+ <span class="card-title">Current trial</span>
1178
+ <span class="card-badge" id="badge-trial-id">—</span>
1179
+ </div>
1180
+ <div class="card-body">
1181
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
1182
+ <button type="button" class="btn sm primary" onclick="startTrialClass('a')">Start · Class A</button>
1183
+ <button type="button" class="btn sm primary" onclick="startTrialClass('b')">Start · Class B</button>
1184
+ </div>
1185
+ <div class="current-trial-info">
1186
+ <div class="trial-class-label" id="trial-class-heading">—</div>
1187
+ <div class="trial-object" id="trial-object-name">—</div>
1188
+ <div class="trial-timer" id="timer-display">No active trial — start Class A or B</div>
1189
+ </div>
1190
+ <div class="progress-wrap">
1191
+ <div class="progress-bar" id="trial-progress" style="width:0%"></div>
1192
+ </div>
1193
+ <div class="section-title" style="margin-top:12px">Self-rating</div>
1194
+ <div class="rating-row">
1195
+ <div class="star-group" id="stars">
1196
+ <div class="star" data-v="1" onclick="rateTrial(1)">1</div>
1197
+ <div class="star" data-v="2" onclick="rateTrial(2)">2</div>
1198
+ <div class="star" data-v="3" onclick="rateTrial(3)">3</div>
1199
+ <div class="star" data-v="4" onclick="rateTrial(4)">4</div>
1200
+ <div class="star sel" data-v="5" onclick="rateTrial(5)">5</div>
1201
+ </div>
1202
+ <span id="rate-label" style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Excellent</span>
1203
+ </div>
1204
+ <div style="display:flex;gap:8px;margin-top:10px">
1205
+ <button class="btn" onclick="sendPrompt('How can I improve EEG trial quality in a mental-imagery paradigm?')">↗ Help</button>
1206
+ <button class="btn primary" onclick="confirmTrial()">Confirm trial →</button>
1207
+ </div>
1208
+ </div>
1209
+ </div>
1210
+
1211
+ <!-- Topographie EEG -->
1212
+ <div class="card">
1213
+ <div class="card-header">
1214
+ <span class="card-title">EEG topomap</span>
1215
+ <span class="card-badge">Live</span>
1216
+ </div>
1217
+ <div class="card-body">
1218
+ <div class="topo-container">
1219
+ <canvas id="topoCanvas" width="160" height="160" style="border-radius:50%"></canvas>
1220
+ </div>
1221
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px">
1222
+ <div class="info-row"><span class="info-key">Alpha</span><span class="info-val" id="live-alpha" style="color:#185FA5">—</span></div>
1223
+ <div class="info-row"><span class="info-key">Beta</span><span class="info-val" id="live-beta" style="color:#0F6E56">—</span></div>
1224
+ <div class="info-row"><span class="info-key">Theta</span><span class="info-val" id="live-theta">—</span></div>
1225
+ <div class="info-row"><span class="info-key">Gamma</span><span class="info-val" id="live-gamma">—</span></div>
1226
+ </div>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <!-- Trial history -->
1231
+ <div class="card" style="grid-column:1/-1">
1232
+ <div class="card-header">
1233
+ <span class="card-title" id="history-card-title">History · Class A</span>
1234
+ <span class="card-badge" id="history-card-badge">0 trials · 0 kept</span>
1235
+ </div>
1236
+ <div class="card-body">
1237
+ <div id="trial-history"></div>
1238
+ </div>
1239
+ </div>
1240
+
1241
+ </div>
1242
+ <div class="panel-footer">
1243
+ <span style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Phases 1–2 active · Auto filter score ≥ 4</span>
1244
+ <div style="display:flex;gap:8px">
1245
+ <button class="btn" onclick="showTab('nf')">→ Neurofeedback</button>
1246
+ </div>
1247
+ </div>
1248
+ </div>
1249
+
1250
+ <!-- ======== PANEL: NEUROFEEDBACK ======== -->
1251
+ <div class="panel" id="panel-nf">
1252
+ <div class="nf-layout">
1253
+
1254
+ <!-- Convergence tracking -->
1255
+ <div class="card nf-full">
1256
+ <div class="card-header">
1257
+ <span class="card-title">Convergence to stable state</span>
1258
+ <span class="card-badge">Phase 3 · Active</span>
1259
+ </div>
1260
+ <div class="card-body">
1261
+ <div style="margin-bottom:10px">
1262
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
1263
+ <span class="section-title" id="nf-a-title">Class A</span>
1264
+ <span id="nf-a-pct" style="font-size:11px;font-family:var(--mono);color:#185FA5">—</span>
1265
+ </div>
1266
+ <div class="convergence-track">
1267
+ <div class="conv-fill" id="nf-a-fill" style="width:0%;background:linear-gradient(to right,#E6F1FB,#B5D4F4)"></div>
1268
+ <div class="conv-pct" id="nf-a-pct-bar">0%</div>
1269
+ <div class="conv-label">Target</div>
1270
+ </div>
1271
+ </div>
1272
+ <div>
1273
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
1274
+ <span class="section-title" id="nf-b-title">Class B</span>
1275
+ <span id="nf-b-pct" style="font-size:11px;font-family:var(--mono);color:#0F6E56">—</span>
1276
+ </div>
1277
+ <div class="convergence-track">
1278
+ <div class="conv-fill" id="nf-b-fill" style="width:0%;background:linear-gradient(to right,#E1F5EE,#9FE1CB)"></div>
1279
+ <div class="conv-pct" id="nf-b-pct-bar">0%</div>
1280
+ <div class="conv-label">Target</div>
1281
+ </div>
1282
+ </div>
1283
+ </div>
1284
+ </div>
1285
+
1286
+ <!-- Signal waveform -->
1287
+ <div class="card">
1288
+ <div class="card-header">
1289
+ <span class="card-title" id="wave-card-title">Raw signal · preview</span>
1290
+ <span class="card-badge">250 Hz</span>
1291
+ </div>
1292
+ <div class="card-body" style="padding:8px">
1293
+ <div class="waveform-wrap">
1294
+ <canvas id="waveCanvas" width="300" height="80"></canvas>
1295
+ </div>
1296
+ </div>
1297
+ </div>
1298
+
1299
+ <!-- Audio feedback -->
1300
+ <div class="card">
1301
+ <div class="card-header">
1302
+ <span class="card-title">Audio feedback (NF)</span>
1303
+ </div>
1304
+ <div class="card-body">
1305
+ <div class="audio-row">
1306
+ <div class="audio-icon pos">
1307
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
1308
+ <path d="M3 7h8M7 3l4 4-4 4" stroke="#0F6E56" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
1309
+ </svg>
1310
+ </div>
1311
+ <div>
1312
+ <div class="audio-text" id="nf-audio-target-title" style="color:#085041;font-weight:500">On target</div>
1313
+ <div class="audio-freq" id="nf-audio-target-detail">—</div>
1314
+ </div>
1315
+ </div>
1316
+ <div class="audio-row">
1317
+ <div class="audio-icon neg">
1318
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
1319
+ <path d="M11 7H3M7 3L3 7l4 4" stroke="#993C1D" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
1320
+ </svg>
1321
+ </div>
1322
+ <div>
1323
+ <div class="audio-text" id="nf-audio-neg-title" style="color:#712B13;font-weight:500">Divergence detected</div>
1324
+ <div class="audio-freq" id="nf-audio-neg-detail">—</div>
1325
+ </div>
1326
+ </div>
1327
+ <div class="audio-row">
1328
+ <div class="audio-icon" style="background:#EEEDFE">
1329
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
1330
+ <circle cx="7" cy="7" r="4" stroke="#534AB7" stroke-width="1.2"/>
1331
+ <circle cx="7" cy="7" r="1.5" fill="#534AB7"/>
1332
+ </svg>
1333
+ </div>
1334
+ <div>
1335
+ <div class="audio-text" id="nf-audio-neutral-title" style="color:#3C3489;font-weight:500">Neutral band</div>
1336
+ <div class="audio-freq" id="nf-audio-neutral-detail">Last trial NF · —</div>
1337
+ </div>
1338
+ </div>
1339
+ </div>
1340
+ </div>
1341
+
1342
+ <!-- Similarity score history -->
1343
+ <div class="card nf-full">
1344
+ <div class="card-header">
1345
+ <span class="card-title">Distance to reference model · history</span>
1346
+ </div>
1347
+ <div class="card-body" style="padding:8px">
1348
+ <canvas id="distCanvas" width="580" height="80"></canvas>
1349
+ </div>
1350
+ </div>
1351
+
1352
+ </div>
1353
+ <div class="panel-footer">
1354
+ <span style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Metric: cosine distance · 8D CSP space</span>
1355
+ <button class="btn primary" onclick="sendPrompt('Which algorithms do you recommend for EEG neurofeedback in mental imagery?')">↗ NF algorithm notes</button>
1356
+ </div>
1357
+ </div>
1358
+
1359
+ <!-- ======== PANEL: MODEL ======== -->
1360
+ <div class="panel" id="panel-model">
1361
+ <div style="padding:16px;display:flex;flex-direction:column;gap:12px;flex:1">
1362
+
1363
+ <div class="card">
1364
+ <div class="card-header">
1365
+ <span class="card-title">Feature extraction pipeline</span>
1366
+ </div>
1367
+ <div class="card-body">
1368
+ <div style="display:flex;align-items:center;gap:0;overflow-x:auto;padding-bottom:4px">
1369
+ <div style="text-align:center;min-width:90px">
1370
+ <div style="width:60px;height:44px;border-radius:8px;background:var(--accent-light);border:0.5px solid #B5D4F4;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#185FA5;text-align:center;padding:4px">EEG raw<br>16 ch</div>
1371
+ </div>
1372
+ <div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
1373
+ <div style="text-align:center;min-width:90px">
1374
+ <div style="width:60px;height:44px;border-radius:8px;background:#E1F5EE;border:0.5px solid #9FE1CB;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#085041;text-align:center;padding:4px">Filter<br>1–40 Hz</div>
1375
+ </div>
1376
+ <div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
1377
+ <div style="text-align:center;min-width:90px">
1378
+ <div style="width:60px;height:44px;border-radius:8px;background:#EEEDFE;border:0.5px solid #CECBF6;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#3C3489;text-align:center;padding:4px">CSP<br>8 comp.</div>
1379
+ </div>
1380
+ <div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
1381
+ <div style="text-align:center;min-width:90px">
1382
+ <div style="width:60px;height:44px;border-radius:8px;background:#FAEEDA;border:0.5px solid #FAC775;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#633806;text-align:center;padding:4px">Log-var<br>features</div>
1383
+ </div>
1384
+ <div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
1385
+ <div style="text-align:center;min-width:90px">
1386
+ <div style="width:60px;height:44px;border-radius:8px;background:#FAECE7;border:0.5px solid #F5C4B3;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#712B13;text-align:center;padding:4px">LDA<br>classifier</div>
1387
+ </div>
1388
+ <div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
1389
+ <div style="text-align:center;min-width:90px">
1390
+ <div style="width:60px;height:44px;border-radius:8px;background:#EAF3DE;border:0.5px solid #C0DD97;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#27500A;text-align:center;padding:4px">Stable<br>state</div>
1391
+ </div>
1392
+ </div>
1393
+ </div>
1394
+ </div>
1395
+
1396
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
1397
+ <div class="card">
1398
+ <div class="card-header"><span class="card-title">CSP parameters</span></div>
1399
+ <div class="card-body">
1400
+ <div class="info-row"><span class="info-key">n_components</span><span class="info-val">8</span></div>
1401
+ <div class="info-row"><span class="info-key">reg</span><span class="info-val">0.05 (Ledoit-Wolf)</span></div>
1402
+ <div class="info-row"><span class="info-key">log</span><span class="info-val">True</span></div>
1403
+ <div class="info-row"><span class="info-key">norm_trace</span><span class="info-val">False</span></div>
1404
+ <div class="info-row"><span class="info-key">epoch window</span><span class="info-val">2–8 s</span></div>
1405
+ </div>
1406
+ </div>
1407
+ <div class="card">
1408
+ <div class="card-header"><span class="card-title">Convergence criterion</span></div>
1409
+ <div class="card-body">
1410
+ <div class="info-row"><span class="info-key">Metric</span><span class="info-val">Cosine distance</span></div>
1411
+ <div class="info-row"><span class="info-key">Convergence threshold</span><span class="info-val">d &lt; 0.12</span></div>
1412
+ <div class="info-row"><span class="info-key">Rolling window</span><span class="info-val">5 trials</span></div>
1413
+ <div class="info-row"><span class="info-key">Min. trials kept</span><span class="info-val">8</span></div>
1414
+ <div class="info-row"><span class="info-key">Min. score</span><span class="info-val">≥ 4 / 5</span></div>
1415
+ </div>
1416
+ </div>
1417
+ </div>
1418
+
1419
+ <button class="btn" style="align-self:flex-start" onclick="sendPrompt('Beyond CSP+LDA, which EEG mental-imagery classifiers work well with few channels?')">↗ Algorithm alternatives</button>
1420
+
1421
+ </div>
1422
+ </div>
1423
+
1424
+ <!-- ======== PANEL: LIBRARY ======== -->
1425
+ <div class="panel" id="panel-lib">
1426
+ <div class="lib-layout">
1427
+
1428
+ <div class="section-title" id="lib-section-title">Library · session</div>
1429
+ <div class="lib-grid">
1430
+ <div class="lib-card" onclick="sendPrompt('How do you compare stable mental states for two imagined objects in a BCI?')">
1431
+ <div class="lib-card-name">Apple</div>
1432
+ <div class="lib-card-meta">Session 1 · 23 trials<br>11 kept · 3 NF sessions</div>
1433
+ <div class="lib-card-bar"><div class="lib-card-fill" style="width:94%;background:#1D9E75"></div></div>
1434
+ </div>
1435
+ <div class="lib-card">
1436
+ <div class="lib-card-name">Cube</div>
1437
+ <div class="lib-card-meta">Session 1 · 20 trials<br>9 kept · 2 NF sessions</div>
1438
+ <div class="lib-card-bar"><div class="lib-card-fill" style="width:88%;background:#185FA5"></div></div>
1439
+ </div>
1440
+ <div class="lib-card">
1441
+ <div class="lib-card-name">Flame</div>
1442
+ <div class="lib-card-meta">Session 2 · 25 trials<br>13 kept · 4 NF sessions</div>
1443
+ <div class="lib-card-bar"><div class="lib-card-fill" style="width:91%;background:#534AB7"></div></div>
1444
+ </div>
1445
+ <div class="lib-card" style="border-color:#FAC775;background:#FAEEDA">
1446
+ <div class="lib-card-name" style="color:#633806">Chair</div>
1447
+ <div class="lib-card-meta" style="color:#854F0B">Session 3 · in progress<br>18 trials · NF active</div>
1448
+ <div class="lib-card-bar"><div class="lib-card-fill" style="width:76%;background:#BA7517"></div></div>
1449
+ </div>
1450
+ <div class="lib-card" style="opacity:0.5;cursor:default">
1451
+ <div class="lib-card-name">Spiral</div>
1452
+ <div class="lib-card-meta">Session 3 · in progress<br>17 trials · phase 2</div>
1453
+ <div class="lib-card-bar"><div class="lib-card-fill" style="width:31%;background:#888"></div></div>
1454
+ </div>
1455
+ <div class="lib-card" style="border-style:dashed;cursor:pointer;display:flex;align-items:center;justify-content:center;min-height:80px" onclick="sendPrompt('Which visual objects work best for a mental-imagery BCI paradigm?')">
1456
+ <div style="text-align:center;color:var(--txt3)">
1457
+ <div style="font-size:20px;margin-bottom:4px">+</div>
1458
+ <div style="font-size:11px">New class</div>
1459
+ </div>
1460
+ </div>
1461
+ </div>
1462
+
1463
+ <div class="section-title" style="margin-top:4px">Cross-subject generalization</div>
1464
+ <div>
1465
+ <div class="subject-row">
1466
+ <div class="subject-avatar" style="background:#E6F1FB;color:#0C447C">S01</div>
1467
+ <div class="subject-info">
1468
+ <div class="subject-name">Subject 01</div>
1469
+ <div class="subject-meta">4 classes · 86 trials · Ref</div>
1470
+ </div>
1471
+ <span class="subject-acc" style="color:#185FA5">91%</span>
1472
+ </div>
1473
+ <div class="subject-row">
1474
+ <div class="subject-avatar" style="background:#E1F5EE;color:#085041">S02</div>
1475
+ <div class="subject-info">
1476
+ <div class="subject-name">Subject 02</div>
1477
+ <div class="subject-meta">4 classes · 72 trials · Transfer</div>
1478
+ </div>
1479
+ <span class="subject-acc" style="color:#0F6E56">78%</span>
1480
+ </div>
1481
+ <div class="subject-row" style="opacity:0.5">
1482
+ <div class="subject-avatar" style="background:#F1EFE8;color:#5F5E5A">S03</div>
1483
+ <div class="subject-info">
1484
+ <div class="subject-name">Subject 03</div>
1485
+ <div class="subject-meta">In progress · session 1</div>
1486
+ </div>
1487
+ <span class="subject-acc" style="color:#888">—</span>
1488
+ </div>
1489
+ </div>
1490
+
1491
+ <button class="btn" style="align-self:flex-start" onclick="sendPrompt('How should I implement transfer learning for cross-subject EEG BCI generalization?')">↗ Cross-subject transfer learning</button>
1492
+
1493
+ </div>
1494
+ </div>
1495
+
1496
+ </div><!-- /main -->
1497
+
1498
+ </div><!-- /app -->
1499
+
1500
+ <script>
1501
+ (function () {
1502
+ 'use strict';
1503
+
1504
+ const API = '';
1505
+ const EPOCH_SEC = 4;
1506
+
1507
+ let channelInfo = null;
1508
+ let lastLive = null;
1509
+ let lastState = null;
1510
+ let ws = null;
1511
+ let wsConnected = false;
1512
+ let restOk = false;
1513
+ let selectedQuality = 5;
1514
+ let nfHistory = [];
1515
+ let lastTrialFeedback = null;
1516
+ let lastNfScore = null;
1517
+
1518
+ function sendPrompt(text) {
1519
+ console.info('[Help]', text);
1520
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1521
+ navigator.clipboard.writeText(text).catch(function () {});
1522
+ }
1523
+ }
1524
+
1525
+ async function api(path, opts) {
1526
+ var r = await fetch(API + path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts || {}));
1527
+ var ct = r.headers.get('content-type') || '';
1528
+ var j = {};
1529
+ if (ct.indexOf('application/json') !== -1) {
1530
+ try { j = await r.json(); } catch (e) { j = {}; }
1531
+ }
1532
+ if (!r.ok) {
1533
+ var msg = (j.detail && (typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail))) || j.error || r.statusText;
1534
+ throw new Error(msg);
1535
+ }
1536
+ if (j.error) throw new Error(j.error);
1537
+ return j;
1538
+ }
1539
+
1540
+ async function loadChannelInfo() {
1541
+ try {
1542
+ channelInfo = await api('/channels/info', { method: 'GET' });
1543
+ } catch (e) {
1544
+ console.warn('channels/info', e);
1545
+ }
1546
+ }
1547
+
1548
+ function wsUrl() {
1549
+ var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
1550
+ return p + '//' + location.host + '/ws';
1551
+ }
1552
+
1553
+ function connectWS() {
1554
+ if (ws && ws.readyState === WebSocket.OPEN) return;
1555
+ try {
1556
+ ws = new WebSocket(wsUrl());
1557
+ } catch (e) {
1558
+ console.warn(e);
1559
+ return;
1560
+ }
1561
+ ws.onopen = function () {
1562
+ wsConnected = true;
1563
+ updateBackendStatus();
1564
+ try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {}
1565
+ };
1566
+ ws.onmessage = function (ev) {
1567
+ var d = JSON.parse(ev.data);
1568
+ if (d.type === 'live_eeg') {
1569
+ lastLive = d;
1570
+ drawTopoFromLive();
1571
+ drawWaveFromLive();
1572
+ updateBandDisplays(d);
1573
+ } else if (d.type === 'state' || d.type === 'state_update') {
1574
+ var copy = Object.assign({}, d);
1575
+ delete copy.type;
1576
+ renderState(copy);
1577
+ } else if (d.type === 'trial_ended') {
1578
+ if (typeof d.nf_score === 'number') {
1579
+ nfHistory.push(d.nf_score);
1580
+ if (nfHistory.length > 80) nfHistory.shift();
1581
+ lastNfScore = d.nf_score;
1582
+ lastTrialFeedback = d.feedback || null;
1583
+ updateAudioFeedbackUI();
1584
+ }
1585
+ refreshState();
1586
+ drawDistCanvas();
1587
+ } else if (d.type === 'pong') {
1588
+ /* ignore */
1589
+ }
1590
+ };
1591
+ ws.onclose = function () {
1592
+ wsConnected = false;
1593
+ updateBackendStatus();
1594
+ setTimeout(connectWS, 2500);
1595
+ };
1596
+ ws.onerror = function () {};
1597
+ }
1598
+
1599
+ function updateBackendStatus() {
1600
+ var el = document.getElementById('backend-status');
1601
+ if (!el) return;
1602
+ var board = lastState && lastState.board_connected;
1603
+ el.textContent =
1604
+ 'REST: ' + (restOk ? 'OK' : '…') +
1605
+ ' · WS: ' + (wsConnected ? 'live' : '…') +
1606
+ ' · Board: ' + (board ? 'on' : 'off');
1607
+ }
1608
+
1609
+ async function refreshState() {
1610
+ try {
1611
+ var st = await api('/state', { method: 'GET' });
1612
+ restOk = true;
1613
+ renderState(st);
1614
+ } catch (e) {
1615
+ restOk = false;
1616
+ console.warn('/state', e);
1617
+ }
1618
+ updateBackendStatus();
1619
+ }
1620
+
1621
+ function findActiveTrial(st) {
1622
+ var all = (st.class_a_trials || []).concat(st.class_b_trials || []);
1623
+ var pending = all.filter(function (t) { return t.quality_score == null; });
1624
+ if (!pending.length) return null;
1625
+ return pending.reduce(function (a, b) { return a.trial_id > b.trial_id ? a : b; });
1626
+ }
1627
+
1628
+ function meanBand(arr) {
1629
+ if (!arr || !arr.length) return 0;
1630
+ var s = 0;
1631
+ for (var i = 0; i < arr.length; i++) s += arr[i];
1632
+ return s / arr.length;
1633
+ }
1634
+
1635
+ function fmtUvSq(v) {
1636
+ if (!isFinite(v) || v === 0) return '—';
1637
+ return v.toExponential(2) + ' (arb.)';
1638
+ }
1639
+
1640
+ function renderState(st) {
1641
+ lastState = st;
1642
+ var cfg = st.config || {};
1643
+ var phase = st.phase || 'IDLE';
1644
+ var dotPhase = document.getElementById('dot-phase');
1645
+ if (dotPhase) {
1646
+ dotPhase.className = 'dot' + (phase !== 'IDLE' ? ' active' : '');
1647
+ }
1648
+ var txtPhase = document.getElementById('txt-phase');
1649
+ if (txtPhase) txtPhase.textContent = phase;
1650
+
1651
+ var sid = cfg.subject_id || '—';
1652
+ var sess = (cfg.session_id || '').split('_').pop() || '—';
1653
+ var txtSession = document.getElementById('txt-session');
1654
+ if (txtSession) txtSession.textContent = sid + ' · ' + sess;
1655
+
1656
+ var dotBoard = document.getElementById('dot-board');
1657
+ if (dotBoard) dotBoard.className = 'dot' + (st.board_connected ? ' active' : '');
1658
+
1659
+ var dotSession = document.getElementById('dot-session');
1660
+ if (dotSession) dotSession.className = 'dot' + (st.total_trials > 0 ? ' warn' : '');
1661
+
1662
+ document.getElementById('ui-class-a-name').textContent = cfg.class_a || 'A';
1663
+ document.getElementById('ui-class-b-name').textContent = cfg.class_b || 'B';
1664
+ var aTrials = st.class_a_trials || [];
1665
+ var bTrials = st.class_b_trials || [];
1666
+ document.getElementById('ui-class-a-n').textContent = aTrials.length + ' trials';
1667
+ document.getElementById('ui-class-b-n').textContent = bTrials.length + ' trials';
1668
+
1669
+ var ns = st.neural_states || {};
1670
+ var keys = Object.keys(ns);
1671
+ var convSum = 0;
1672
+ var convN = 0;
1673
+ keys.forEach(function (k) {
1674
+ convSum += ns[k].convergence_score || 0;
1675
+ convN++;
1676
+ });
1677
+ document.getElementById('metric-conv').textContent =
1678
+ convN ? Math.round((convSum / convN) * 100) + '%' : '—';
1679
+
1680
+ var minQ = cfg.min_quality_for_model != null ? cfg.min_quality_for_model : 4;
1681
+ var allRated = aTrials.concat(bTrials).filter(function (t) { return t.quality_score != null; });
1682
+ var kept = allRated.filter(function (t) { return t.quality_score >= minQ; }).length;
1683
+ document.getElementById('metric-kept').textContent = kept + ' / ' + allRated.length;
1684
+
1685
+ var scN = 0;
1686
+ var totalSc = 0;
1687
+ allRated.forEach(function (t) {
1688
+ totalSc += t.quality_score;
1689
+ scN++;
1690
+ });
1691
+ document.getElementById('metric-mean').textContent =
1692
+ scN ? (totalSc / scN).toFixed(1) + ' / 5' : '—';
1693
+
1694
+ document.getElementById('metric-lda').textContent = st.classifier_trained ? 'trained' : 'not trained';
1695
+
1696
+ var libList = document.getElementById('sidebar-library-list');
1697
+ if (libList) {
1698
+ libList.innerHTML = '';
1699
+ keys.forEach(function (label) {
1700
+ var info = ns[label];
1701
+ var row = document.createElement('div');
1702
+ row.className = 'lib-entry';
1703
+ row.innerHTML =
1704
+ '<span class="lib-dot" style="background:#185FA5"></span>' +
1705
+ '<span class="lib-text">' +
1706
+ label +
1707
+ '</span>' +
1708
+ '<span class="lib-score">' +
1709
+ Math.round((info.convergence_score || 0) * 100) +
1710
+ '% · n=' +
1711
+ info.n_trials +
1712
+ '</span>';
1713
+ libList.appendChild(row);
1714
+ });
1715
+ if (!keys.length) {
1716
+ libList.innerHTML = '<div class="muted" style="font-size:11px;padding:4px">No stable states yet</div>';
1717
+ }
1718
+ }
1719
+
1720
+ var ca = cfg.class_a || 'Class A';
1721
+ var cb = cfg.class_b || 'Class B';
1722
+ var pctA = ns[ca] ? Math.round((ns[ca].convergence_score || 0) * 100) : 0;
1723
+ var pctB = ns[cb] ? Math.round((ns[cb].convergence_score || 0) * 100) : 0;
1724
+
1725
+ var nfAT = document.getElementById('nf-a-title');
1726
+ var nfBT = document.getElementById('nf-b-title');
1727
+ if (nfAT) nfAT.textContent = ca + ' (Class A)';
1728
+ if (nfBT) nfBT.textContent = cb + ' (Class B)';
1729
+
1730
+ var elAp = document.getElementById('nf-a-pct');
1731
+ var elBp = document.getElementById('nf-b-pct');
1732
+ if (elAp) elAp.textContent = ns[ca] ? pctA + '% converged' : '—';
1733
+ if (elBp) elBp.textContent = ns[cb] ? pctB + '% converged' : '—';
1734
+
1735
+ var fillA = document.getElementById('nf-a-fill');
1736
+ var fillB = document.getElementById('nf-b-fill');
1737
+ var barA = document.getElementById('nf-a-pct-bar');
1738
+ var barB = document.getElementById('nf-b-pct-bar');
1739
+ if (fillA) fillA.style.width = pctA + '%';
1740
+ if (fillB) fillB.style.width = pctB + '%';
1741
+ if (barA) barA.textContent = pctA + '%';
1742
+ if (barB) barB.textContent = pctB + '%';
1743
+
1744
+ var active = findActiveTrial(st);
1745
+ var badge = document.getElementById('badge-trial-id');
1746
+ var obj = document.getElementById('trial-object-name');
1747
+ var head = document.getElementById('trial-class-heading');
1748
+ if (badge) badge.textContent = active ? 'Trial ' + active.trial_id : '—';
1749
+ if (obj) obj.textContent = active ? active.class_label : '—';
1750
+ if (head) {
1751
+ if (!active) head.textContent = '—';
1752
+ else if (active.class_label === ca) head.textContent = 'CLASS A · Imagine';
1753
+ else if (active.class_label === cb) head.textContent = 'CLASS B · Imagine';
1754
+ else head.textContent = active.class_label;
1755
+ }
1756
+
1757
+ var ht = document.getElementById('history-card-title');
1758
+ var hb = document.getElementById('history-card-badge');
1759
+ if (ht) ht.textContent = 'History · Class A (' + ca + ')';
1760
+ var ratedA = aTrials.filter(function (t) { return t.quality_score != null; });
1761
+ var keptA = ratedA.filter(function (t) { return t.quality_score >= minQ; }).length;
1762
+ if (hb) hb.textContent = ratedA.length + ' trials · ' + keptA + ' kept';
1763
+
1764
+ renderHistoryClassA(st, minQ, ca);
1765
+
1766
+ var libTitle = document.getElementById('lib-section-title');
1767
+ if (libTitle) libTitle.textContent = 'Library · ' + sid;
1768
+
1769
+ updateBackendStatus();
1770
+ updateTrialTimer(st);
1771
+ updateAudioFeedbackUI();
1772
+ }
1773
+
1774
+ function updateAudioFeedbackUI() {
1775
+ var fb = lastTrialFeedback;
1776
+ var t1 = document.getElementById('nf-audio-target-detail');
1777
+ var t2 = document.getElementById('nf-audio-neg-detail');
1778
+ var t3 = document.getElementById('nf-audio-neutral-detail');
1779
+ if (fb && t1) {
1780
+ t1.textContent = fb.freq_hz + ' Hz · vol ' + fb.volume + ' · ' + fb.tone;
1781
+ } else if (t1) t1.textContent = '—';
1782
+ if (t2) t2.textContent = lastNfScore != null && lastNfScore < 0.35 ? 'Low NF score' : '—';
1783
+ if (t3) {
1784
+ t3.textContent =
1785
+ 'Last trial NF · ' + (lastNfScore != null ? lastNfScore.toFixed(3) : '—');
1786
+ }
1787
+ }
1788
+
1789
+ function updateTrialTimer(st) {
1790
+ var active = findActiveTrial(st);
1791
+ var el = document.getElementById('timer-display');
1792
+ var prog = document.getElementById('trial-progress');
1793
+ if (!active) {
1794
+ if (el) el.textContent = 'No active trial — start Class A or B';
1795
+ if (prog) prog.style.width = '0%';
1796
+ return;
1797
+ }
1798
+ var onsetMs = active.onset_time * 1000;
1799
+ var elapsed = (Date.now() - onsetMs) / 1000;
1800
+ var left = Math.max(0, EPOCH_SEC - elapsed);
1801
+ if (el) {
1802
+ el.textContent =
1803
+ left.toFixed(1) +
1804
+ ' s left · Trial #' +
1805
+ active.trial_id +
1806
+ ' · ' +
1807
+ active.class_label;
1808
+ }
1809
+ if (prog) prog.style.width = Math.min(100, (elapsed / EPOCH_SEC) * 100) + '%';
1810
+ }
1811
+
1812
+ function renderHistoryClassA(st, minQ, classAName) {
1813
+ var h = document.getElementById('trial-history');
1814
+ if (!h) return;
1815
+ h.innerHTML = '';
1816
+ var trials = (st.class_a_trials || []).filter(function (t) {
1817
+ return t.quality_score != null;
1818
+ });
1819
+ var colors = ['', '#E24B4A', '#EF9F27', '#888', '#1D9E75', '#185FA5'];
1820
+ trials.forEach(function (t, i) {
1821
+ var r = t.quality_score;
1822
+ var keep = r >= minQ;
1823
+ var barW = (r / 5) * 100;
1824
+ var row = document.createElement('div');
1825
+ row.className = 'trial-row';
1826
+ row.innerHTML =
1827
+ '<div class="trial-num">' +
1828
+ t.trial_id +
1829
+ '</div>' +
1830
+ '<div class="trial-bar-wrap"><div class="trial-bar" style="width:' +
1831
+ barW +
1832
+ '%;background:' +
1833
+ colors[r] +
1834
+ '"></div></div>' +
1835
+ '<div class="trial-score" style="color:' +
1836
+ colors[r] +
1837
+ '">' +
1838
+ r +
1839
+ '</div>' +
1840
+ '<div class="trial-badge ' +
1841
+ (keep ? 'keep' : 'drop') +
1842
+ '">' +
1843
+ (keep ? '✓ keep' : '✗ drop') +
1844
+ '</div>';
1845
+ h.appendChild(row);
1846
+ });
1847
+ if (!trials.length) {
1848
+ h.innerHTML =
1849
+ '<div class="muted" style="font-size:11px;padding:8px">No Class A trials rated yet (' +
1850
+ classAName +
1851
+ ').</div>';
1852
+ }
1853
+ }
1854
+
1855
+ function channelPositionsForTopo() {
1856
+ if (!channelInfo || !channelInfo.channel_names) return null;
1857
+ var pos = channelInfo.positions || {};
1858
+ var list = [];
1859
+ channelInfo.channel_names.forEach(function (name) {
1860
+ var xy = pos[name];
1861
+ if (!xy) return;
1862
+ var px = Array.isArray(xy) ? xy[0] : xy.x;
1863
+ var py = Array.isArray(xy) ? xy[1] : xy.y;
1864
+ list.push({ name: name, x: 80 + px * 100, y: 80 - py * 100 });
1865
+ });
1866
+ return list.length ? list : null;
1867
+ }
1868
+
1869
+ function drawTopoFromLive() {
1870
+ var c = document.getElementById('topoCanvas');
1871
+ if (!c) return;
1872
+ var ctx = c.getContext('2d');
1873
+ var W = 160;
1874
+ var H = 160;
1875
+ var cx = 80;
1876
+ var cy = 80;
1877
+ var R = 72;
1878
+
1879
+ var topo = lastLive && lastLive.topomap;
1880
+ var base = channelPositionsForTopo();
1881
+ var channels;
1882
+ if (base && topo) {
1883
+ var vals = base.map(function (ch) {
1884
+ return topo[ch.name] != null ? topo[ch.name] : 0;
1885
+ });
1886
+ var vmin = Math.min.apply(null, vals);
1887
+ var vmax = Math.max.apply(null, vals);
1888
+ var span = vmax - vmin || 1;
1889
+ channels = base.map(function (ch, i) {
1890
+ return {
1891
+ x: ch.x,
1892
+ y: ch.y,
1893
+ v: (vals[i] - vmin) / span,
1894
+ };
1895
+ });
1896
+ } else {
1897
+ channels = [
1898
+ { x: 80, y: 20, v: 0.35 },
1899
+ { x: 120, y: 35, v: 0.5 },
1900
+ { x: 140, y: 70, v: 0.55 },
1901
+ { x: 140, y: 110, v: 0.6 },
1902
+ { x: 120, y: 135, v: 0.5 },
1903
+ { x: 80, y: 148, v: 0.45 },
1904
+ { x: 40, y: 135, v: 0.5 },
1905
+ { x: 20, y: 110, v: 0.55 },
1906
+ { x: 20, y: 70, v: 0.58 },
1907
+ { x: 40, y: 35, v: 0.4 },
1908
+ { x: 80, y: 60, v: 0.3 },
1909
+ { x: 110, y: 75, v: 0.48 },
1910
+ { x: 110, y: 105, v: 0.52 },
1911
+ { x: 80, y: 118, v: 0.5 },
1912
+ { x: 50, y: 105, v: 0.53 },
1913
+ { x: 50, y: 75, v: 0.47 },
1914
+ ];
1915
+ }
1916
+
1917
+ var imgData = ctx.createImageData(W, H);
1918
+ for (var px = 0; px < W; px++) {
1919
+ for (var py = 0; py < H; py++) {
1920
+ var dx = px - cx;
1921
+ var dy = py - cy;
1922
+ if (dx * dx + dy * dy > R * R) continue;
1923
+ var wSum = 0;
1924
+ var vSum = 0;
1925
+ channels.forEach(function (ch) {
1926
+ var d2 = (px - ch.x) * (px - ch.x) + (py - ch.y) * (py - ch.y) + 0.001;
1927
+ var w = 1 / d2;
1928
+ wSum += w;
1929
+ vSum += w * ch.v;
1930
+ });
1931
+ var v = vSum / wSum;
1932
+ var r;
1933
+ var g;
1934
+ var b;
1935
+ if (v < 0.5) {
1936
+ var t = v * 2;
1937
+ r = Math.round(t * 100);
1938
+ g = Math.round(t * 150);
1939
+ b = Math.round(180 + t * 50);
1940
+ } else {
1941
+ var t2 = (v - 0.5) * 2;
1942
+ r = Math.round(100 + t2 * 155);
1943
+ g = Math.round(150 - t2 * 100);
1944
+ b = Math.round(230 - t2 * 210);
1945
+ }
1946
+ var idx = (py * W + px) * 4;
1947
+ imgData.data[idx] = r;
1948
+ imgData.data[idx + 1] = g;
1949
+ imgData.data[idx + 2] = b;
1950
+ imgData.data[idx + 3] = 200;
1951
+ }
1952
+ }
1953
+ ctx.clearRect(0, 0, W, H);
1954
+ ctx.save();
1955
+ ctx.beginPath();
1956
+ ctx.arc(cx, cy, R, 0, Math.PI * 2);
1957
+ ctx.clip();
1958
+ ctx.putImageData(imgData, 0, 0);
1959
+ ctx.restore();
1960
+ ctx.beginPath();
1961
+ ctx.arc(cx, cy, R, 0, Math.PI * 2);
1962
+ ctx.strokeStyle = 'rgba(128,128,128,0.3)';
1963
+ ctx.lineWidth = 0.5;
1964
+ ctx.stroke();
1965
+ ctx.beginPath();
1966
+ ctx.arc(4, 80, 4, 0, Math.PI * 2);
1967
+ ctx.fillStyle = 'rgba(128,128,128,0.3)';
1968
+ ctx.fill();
1969
+ ctx.beginPath();
1970
+ ctx.arc(156, 80, 4, 0, Math.PI * 2);
1971
+ ctx.fill();
1972
+ channels.forEach(function (ch) {
1973
+ ctx.beginPath();
1974
+ ctx.arc(ch.x, ch.y, 3, 0, Math.PI * 2);
1975
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
1976
+ ctx.fill();
1977
+ ctx.strokeStyle = 'rgba(0,0,0,0.3)';
1978
+ ctx.lineWidth = 0.5;
1979
+ ctx.stroke();
1980
+ });
1981
+ }
1982
+
1983
+ function drawWaveFromLive() {
1984
+ var c = document.getElementById('waveCanvas');
1985
+ if (!c) return;
1986
+ var parent = c.parentElement;
1987
+ var rect = parent ? parent.getBoundingClientRect() : { width: 300 };
1988
+ var dpr = window.devicePixelRatio || 1;
1989
+ var W = Math.floor(rect.width * dpr) || 300;
1990
+ var H = Math.floor(80 * dpr);
1991
+ if (c.width !== W) c.width = W;
1992
+ if (c.height !== H) c.height = H;
1993
+ c.style.width = W / dpr + 'px';
1994
+ c.style.height = H / dpr + 'px';
1995
+
1996
+ var ctx = c.getContext('2d');
1997
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1998
+ ctx.clearRect(0, 0, W, H);
1999
+ ctx.scale(dpr, dpr);
2000
+ var drawW = W / dpr;
2001
+ var drawH = H / dpr;
2002
+
2003
+ var raw = lastLive && lastLive.raw_samples;
2004
+ if (!raw || !raw.length) {
2005
+ ctx.strokeStyle = 'rgba(24,95,165,0.25)';
2006
+ ctx.beginPath();
2007
+ ctx.moveTo(0, drawH / 2);
2008
+ ctx.lineTo(drawW, drawH / 2);
2009
+ ctx.stroke();
2010
+ return;
2011
+ }
2012
+
2013
+ var colors = ['#185FA5', '#0F6E56', '#3C3489', '#BA7517'];
2014
+ for (var ch = 0; ch < raw.length; ch++) {
2015
+ var samp = raw[ch];
2016
+ if (!samp || !samp.length) continue;
2017
+ ctx.beginPath();
2018
+ var minV = samp[0];
2019
+ var maxV = samp[0];
2020
+ for (var i = 1; i < samp.length; i++) {
2021
+ if (samp[i] < minV) minV = samp[i];
2022
+ if (samp[i] > maxV) maxV = samp[i];
2023
+ }
2024
+ var span = maxV - minV || 1;
2025
+ for (var x = 0; x < samp.length; x++) {
2026
+ var nx = (x / (samp.length - 1)) * drawW;
2027
+ var ny = drawH * 0.15 + (1 - (samp[x] - minV) / span) * (drawH * 0.7) + ch * 4;
2028
+ if (x === 0) ctx.moveTo(nx, ny);
2029
+ else ctx.lineTo(nx, ny);
2030
+ }
2031
+ ctx.strokeStyle = colors[ch % colors.length];
2032
+ ctx.lineWidth = 1;
2033
+ ctx.stroke();
2034
+ }
2035
+ }
2036
+
2037
+ function updateBandDisplays(d) {
2038
+ if (!d) return;
2039
+ var a = document.getElementById('live-alpha');
2040
+ var b = document.getElementById('live-beta');
2041
+ var t = document.getElementById('live-theta');
2042
+ var g = document.getElementById('live-gamma');
2043
+ if (a) a.textContent = d.alpha_power ? fmtUvSq(meanBand(d.alpha_power)) : '—';
2044
+ if (b) b.textContent = d.beta_power ? fmtUvSq(meanBand(d.beta_power)) : '—';
2045
+ if (t) t.textContent = d.theta_power ? fmtUvSq(meanBand(d.theta_power)) : '—';
2046
+ if (g) g.textContent = d.gamma_power ? fmtUvSq(meanBand(d.gamma_power)) : '—';
2047
+
2048
+ var names = d.channels || (channelInfo && channelInfo.channel_names);
2049
+ var wtitle = document.getElementById('wave-card-title');
2050
+ if (wtitle && names && names.length >= 4) {
2051
+ wtitle.textContent =
2052
+ 'Raw signal · ' + names[0] + ' · ' + names[4] + ' · ' + names[8] + ' · ' + names[12];
2053
+ }
2054
+ }
2055
+
2056
+ function drawDistCanvas() {
2057
+ var c = document.getElementById('distCanvas');
2058
+ if (!c) return;
2059
+ var ctx = c.getContext('2d');
2060
+ var rect = c.parentElement ? c.parentElement.getBoundingClientRect() : { width: 580 };
2061
+ var W = Math.floor(rect.width) || 580;
2062
+ var H = 80;
2063
+ if (c.width !== W) c.width = W;
2064
+ c.height = H;
2065
+
2066
+ var data = nfHistory.slice();
2067
+ ctx.clearRect(0, 0, W, H);
2068
+ var pad = 20;
2069
+ var maxD = 1;
2070
+ ctx.beginPath();
2071
+ ctx.setLineDash([3, 3]);
2072
+ var ty = H - pad - (0.35 / maxD) * (H - 2 * pad);
2073
+ ctx.moveTo(pad, ty);
2074
+ ctx.lineTo(W - pad, ty);
2075
+ ctx.strokeStyle = 'rgba(128,128,128,0.4)';
2076
+ ctx.lineWidth = 0.5;
2077
+ ctx.stroke();
2078
+ ctx.setLineDash([]);
2079
+ if (data.length < 2) {
2080
+ ctx.font = '11px monospace';
2081
+ ctx.fillStyle = 'rgba(128,128,128,0.7)';
2082
+ ctx.fillText('NF scores appear after rated trials', pad, H / 2);
2083
+ return;
2084
+ }
2085
+ ctx.beginPath();
2086
+ data.forEach(function (d, i) {
2087
+ var x = pad + (i * (W - 2 * pad)) / (data.length - 1);
2088
+ var y = H - pad - (d / maxD) * (H - 2 * pad);
2089
+ if (i === 0) ctx.moveTo(x, y);
2090
+ else ctx.lineTo(x, y);
2091
+ });
2092
+ ctx.strokeStyle = '#185FA5';
2093
+ ctx.lineWidth = 1.2;
2094
+ ctx.stroke();
2095
+ data.forEach(function (d, i) {
2096
+ var x = pad + (i * (W - 2 * pad)) / (data.length - 1);
2097
+ var y = H - pad - (d / maxD) * (H - 2 * pad);
2098
+ ctx.beginPath();
2099
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
2100
+ ctx.fillStyle = d >= 0.65 ? '#1D9E75' : '#185FA5';
2101
+ ctx.fill();
2102
+ });
2103
+ ctx.font = '10px monospace';
2104
+ ctx.fillStyle = 'rgba(128,128,128,0.7)';
2105
+ ctx.fillText('NF similarity (higher is better)', pad, 12);
2106
+ }
2107
+
2108
+ window.showTab = function (t) {
2109
+ document.querySelectorAll('.tab').forEach(function (x) {
2110
+ x.classList.remove('active');
2111
+ });
2112
+ document.querySelectorAll('.panel').forEach(function (x) {
2113
+ x.classList.remove('active');
2114
+ });
2115
+ document.getElementById('tab-' + t).classList.add('active');
2116
+ document.getElementById('panel-' + t).classList.add('active');
2117
+ if (t === 'nf') drawDistCanvas();
2118
+ if (t === 'acq') drawTopoFromLive();
2119
+ if (t === 'nf') drawWaveFromLive();
2120
+ };
2121
+
2122
+ window.rateTrial = function (v) {
2123
+ selectedQuality = v;
2124
+ document.querySelectorAll('.star').forEach(function (s) {
2125
+ s.classList.toggle('sel', parseInt(s.dataset.v, 10) === v);
2126
+ });
2127
+ var labels = ['', 'Poor', 'Fair', 'Average', 'Good', 'Excellent'];
2128
+ document.getElementById('rate-label').textContent = labels[v];
2129
+ };
2130
+
2131
+ window.applyConfiguration = async function () {
2132
+ var subject_id = document.getElementById('cfg-subject').value.trim();
2133
+ var class_a = document.getElementById('cfg-class-a').value.trim();
2134
+ var class_b = document.getElementById('cfg-class-b').value.trim();
2135
+ var min_quality = parseInt(document.getElementById('cfg-min-q').value, 10) || 4;
2136
+ var q =
2137
+ '/session/configure?subject_id=' +
2138
+ encodeURIComponent(subject_id) +
2139
+ '&class_a=' +
2140
+ encodeURIComponent(class_a) +
2141
+ '&class_b=' +
2142
+ encodeURIComponent(class_b) +
2143
+ '&min_quality=' +
2144
+ min_quality;
2145
+ await api(q, { method: 'POST' });
2146
+ await refreshState();
2147
+ };
2148
+
2149
+ window.connectBoard = async function (simulate) {
2150
+ var port =
2151
+ document.getElementById('cfg-port').value.trim() || '/dev/ttyUSB0';
2152
+ var q =
2153
+ '/board/connect?port=' +
2154
+ encodeURIComponent(port) +
2155
+ '&simulate=' +
2156
+ (!!simulate);
2157
+ await api(q, { method: 'POST' });
2158
+ connectWS();
2159
+ await refreshState();
2160
+ };
2161
+
2162
+ window.disconnectBoard = async function () {
2163
+ await api('/board/disconnect', { method: 'POST' });
2164
+ await refreshState();
2165
+ };
2166
+
2167
+ window.resetSession = async function () {
2168
+ await api('/session/reset', { method: 'POST' });
2169
+ nfHistory = [];
2170
+ lastNfScore = null;
2171
+ lastTrialFeedback = null;
2172
+ await refreshState();
2173
+ drawDistCanvas();
2174
+ };
2175
+
2176
+ window.startTrialClass = async function (ab) {
2177
+ var ca = document.getElementById('cfg-class-a').value.trim();
2178
+ var cb = document.getElementById('cfg-class-b').value.trim();
2179
+ var label = ab === 'a' ? ca : cb;
2180
+ if (!label) return;
2181
+ await api('/trial/start?class_label=' + encodeURIComponent(label), { method: 'POST' });
2182
+ await refreshState();
2183
+ };
2184
+
2185
+ window.confirmTrial = async function () {
2186
+ try {
2187
+ var res = await api('/trial/end?quality=' + selectedQuality, { method: 'POST' });
2188
+ if (typeof res.nf_score === 'number') {
2189
+ nfHistory.push(res.nf_score);
2190
+ if (nfHistory.length > 80) nfHistory.shift();
2191
+ lastNfScore = res.nf_score;
2192
+ lastTrialFeedback = res.feedback || null;
2193
+ updateAudioFeedbackUI();
2194
+ }
2195
+ await refreshState();
2196
+ drawDistCanvas();
2197
+ } catch (e) {
2198
+ alert(e.message || String(e));
2199
+ }
2200
+ };
2201
+
2202
+ function tickLoop() {
2203
+ if (lastState) updateTrialTimer(lastState);
2204
+ }
2205
+
2206
+ window.addEventListener('load', async function () {
2207
+ await loadChannelInfo();
2208
+ try {
2209
+ await api('/health', { method: 'GET' });
2210
+ restOk = true;
2211
+ } catch (e) {
2212
+ restOk = false;
2213
+ }
2214
+ connectWS();
2215
+ await refreshState();
2216
+ setInterval(refreshState, 4000);
2217
+ setInterval(tickLoop, 100);
2218
+ setInterval(function () {
2219
+ if (lastLive && document.getElementById('panel-acq').classList.contains('active')) {
2220
+ drawTopoFromLive();
2221
+ }
2222
+ }, 200);
2223
+ drawTopoFromLive();
2224
+ drawDistCanvas();
2225
+ });
2226
+
2227
+ window.addEventListener('resize', function () {
2228
+ drawWaveFromLive();
2229
+ drawDistCanvas();
2230
+ });
2231
+ })();
2232
+ </script>
2233
+
2234
+ </body>
2235
+ </html>