mudra-skills 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/CLAUDE.md +35 -0
  3. package/bin/install.js +25 -0
  4. package/package.json +28 -0
  5. package/skills/mudra-master/SKILL.md +202 -0
  6. package/skills/mudra-master/mudra-preview/SKILL.md +151 -0
  7. package/skills/mudra-master/mudra-preview/assets/ar-menu.html +382 -0
  8. package/skills/mudra-master/mudra-preview/assets/document-scroller.html +468 -0
  9. package/skills/mudra-master/mudra-preview/assets/drum-machine.html +788 -0
  10. package/skills/mudra-master/mudra-preview/assets/emg-visualizer.html +390 -0
  11. package/skills/mudra-master/mudra-preview/assets/generative-art.html +408 -0
  12. package/skills/mudra-master/mudra-preview/assets/gesture-assistant.html +342 -0
  13. package/skills/mudra-master/mudra-preview/assets/gesture-speech.html +444 -0
  14. package/skills/mudra-master/mudra-preview/assets/hands-free-desktop.html +376 -0
  15. package/skills/mudra-master/mudra-preview/assets/model-rotator.html +365 -0
  16. package/skills/mudra-master/mudra-preview/assets/mudra-duel.html +3200 -0
  17. package/skills/mudra-master/mudra-preview/assets/mudra-monitor.html +1451 -0
  18. package/skills/mudra-master/mudra-preview/assets/mudra-ultimate-template.html +891 -0
  19. package/skills/mudra-master/mudra-preview/assets/music-sequencer.html +455 -0
  20. package/skills/mudra-master/mudra-preview/assets/neural-pong.html +380 -0
  21. package/skills/mudra-master/mudra-preview/assets/neural-snake.html +364 -0
  22. package/skills/mudra-master/mudra-preview/assets/presentation-controller.html +345 -0
  23. package/skills/mudra-master/mudra-preview/assets/pressure-painter.html +285 -0
  24. package/skills/mudra-master/mudra-preview/assets/runner.html +1812 -0
  25. package/skills/mudra-master/mudra-preview/assets/smart-home.html +461 -0
  26. package/skills/mudra-master/mudra-preview/assets/space-invaders.html +1154 -0
  27. package/skills/mudra-master/mudra-preview/assets/waterful-ring-toss.html +1624 -0
  28. package/skills/mudra-master/mudra-preview/references/agent_protocol.json +646 -0
  29. package/skills/mudra-master/mudra-preview/references/promt.md +16829 -0
  30. package/skills/mudra-master/mudra-xr/SKILL.md +245 -0
  31. package/skills/mudra-master/mudra-xr/assets/demos/3dgs-walkthrough.html +242 -0
  32. package/skills/mudra-master/mudra-xr/assets/demos/aisimulator.html +792 -0
  33. package/skills/mudra-master/mudra-xr/assets/demos/balloonpop.html +422 -0
  34. package/skills/mudra-master/mudra-xr/assets/demos/ballpit.html +284 -0
  35. package/skills/mudra-master/mudra-xr/assets/demos/drone.html +41 -0
  36. package/skills/mudra-master/mudra-xr/assets/demos/gemini-icebreakers.html +338 -0
  37. package/skills/mudra-master/mudra-xr/assets/demos/gemini-xrobject.html +302 -0
  38. package/skills/mudra-master/mudra-xr/assets/demos/math3d.html +188 -0
  39. package/skills/mudra-master/mudra-xr/assets/demos/measure.html +247 -0
  40. package/skills/mudra-master/mudra-xr/assets/demos/occlusion.html +251 -0
  41. package/skills/mudra-master/mudra-xr/assets/demos/rain.html +498 -0
  42. package/skills/mudra-master/mudra-xr/assets/demos/screenwiper.html +432 -0
  43. package/skills/mudra-master/mudra-xr/assets/demos/splash.html +519 -0
  44. package/skills/mudra-master/mudra-xr/assets/demos/webcam_gestures.html +253 -0
  45. package/skills/mudra-master/mudra-xr/assets/demos/xremoji.html +833 -0
  46. package/skills/mudra-master/mudra-xr/assets/demos/xrpoet.html +151 -0
  47. package/skills/mudra-master/mudra-xr/assets/samples/depthmap.html +266 -0
  48. package/skills/mudra-master/mudra-xr/assets/samples/depthmesh.html +100 -0
  49. package/skills/mudra-master/mudra-xr/assets/samples/game_rps.html +1099 -0
  50. package/skills/mudra-master/mudra-xr/assets/samples/gestures_custom.html +467 -0
  51. package/skills/mudra-master/mudra-xr/assets/samples/gestures_heuristic.html +256 -0
  52. package/skills/mudra-master/mudra-xr/assets/samples/lighting.html +163 -0
  53. package/skills/mudra-master/mudra-xr/assets/samples/mesh_detection.html +46 -0
  54. package/skills/mudra-master/mudra-xr/assets/samples/modelviewer.html +183 -0
  55. package/skills/mudra-master/mudra-xr/assets/samples/paint.html +133 -0
  56. package/skills/mudra-master/mudra-xr/assets/samples/planar-vst.html +45 -0
  57. package/skills/mudra-master/mudra-xr/assets/samples/reticle.html +95 -0
  58. package/skills/mudra-master/mudra-xr/assets/samples/skybox_agent.html +384 -0
  59. package/skills/mudra-master/mudra-xr/assets/samples/sound.html +390 -0
  60. package/skills/mudra-master/mudra-xr/assets/samples/ui.html +496 -0
  61. package/skills/mudra-master/mudra-xr/assets/samples/virtual-screens.html +808 -0
  62. package/skills/mudra-master/mudra-xr/assets/templates/0_basic.html +64 -0
  63. package/skills/mudra-master/mudra-xr/assets/templates/1_ui.html +107 -0
  64. package/skills/mudra-master/mudra-xr/assets/templates/2_hands.html +183 -0
  65. package/skills/mudra-master/mudra-xr/assets/templates/3_depth.html +89 -0
  66. package/skills/mudra-master/mudra-xr/assets/templates/4_stereo.html +84 -0
  67. package/skills/mudra-master/mudra-xr/assets/templates/5_camera.html +117 -0
  68. package/skills/mudra-master/mudra-xr/assets/templates/6_ai.html +154 -0
  69. package/skills/mudra-master/mudra-xr/assets/templates/7_ai_live.html +253 -0
  70. package/skills/mudra-master/mudra-xr/assets/templates/8_objects.html +95 -0
  71. package/skills/mudra-master/mudra-xr/assets/templates/9_xr-toggle.html +90 -0
  72. package/skills/mudra-master/mudra-xr/assets/templates/heuristic_hand_gestures.html +102 -0
  73. package/skills/mudra-master/mudra-xr/assets/templates/meshes.html +47 -0
  74. package/skills/mudra-master/mudra-xr/assets/templates/planes.html +47 -0
  75. package/skills/mudra-master/mudra-xr/assets/templates/uikit.html +187 -0
  76. package/skills/mudra-master/mudra-xr/references/agent_protocol.json +878 -0
  77. package/skills/mudra-master/mudra-xr/references/promt.md +1516 -0
  78. package/skills/mudra-preview/SKILL.md +151 -0
  79. package/skills/mudra-preview/assets/ar-menu.html +382 -0
  80. package/skills/mudra-preview/assets/document-scroller.html +468 -0
  81. package/skills/mudra-preview/assets/drum-machine.html +788 -0
  82. package/skills/mudra-preview/assets/emg-visualizer.html +390 -0
  83. package/skills/mudra-preview/assets/generative-art.html +408 -0
  84. package/skills/mudra-preview/assets/gesture-assistant.html +342 -0
  85. package/skills/mudra-preview/assets/gesture-speech.html +444 -0
  86. package/skills/mudra-preview/assets/hands-free-desktop.html +376 -0
  87. package/skills/mudra-preview/assets/model-rotator.html +365 -0
  88. package/skills/mudra-preview/assets/mudra-duel.html +3200 -0
  89. package/skills/mudra-preview/assets/mudra-monitor.html +1451 -0
  90. package/skills/mudra-preview/assets/mudra-ultimate-template.html +891 -0
  91. package/skills/mudra-preview/assets/music-sequencer.html +455 -0
  92. package/skills/mudra-preview/assets/neural-pong.html +380 -0
  93. package/skills/mudra-preview/assets/neural-snake.html +364 -0
  94. package/skills/mudra-preview/assets/presentation-controller.html +345 -0
  95. package/skills/mudra-preview/assets/pressure-painter.html +285 -0
  96. package/skills/mudra-preview/assets/runner.html +1812 -0
  97. package/skills/mudra-preview/assets/smart-home.html +461 -0
  98. package/skills/mudra-preview/assets/space-invaders.html +1154 -0
  99. package/skills/mudra-preview/assets/waterful-ring-toss.html +1624 -0
  100. package/skills/mudra-preview/references/agent_protocol.json +646 -0
  101. package/skills/mudra-preview/references/promt.md +16829 -0
  102. package/skills/mudra-xr/SKILL.md +245 -0
  103. package/skills/mudra-xr/assets/demos/3dgs-walkthrough.html +242 -0
  104. package/skills/mudra-xr/assets/demos/aisimulator.html +792 -0
  105. package/skills/mudra-xr/assets/demos/balloonpop.html +422 -0
  106. package/skills/mudra-xr/assets/demos/ballpit.html +284 -0
  107. package/skills/mudra-xr/assets/demos/drone.html +41 -0
  108. package/skills/mudra-xr/assets/demos/gemini-icebreakers.html +338 -0
  109. package/skills/mudra-xr/assets/demos/gemini-xrobject.html +302 -0
  110. package/skills/mudra-xr/assets/demos/math3d.html +188 -0
  111. package/skills/mudra-xr/assets/demos/measure.html +247 -0
  112. package/skills/mudra-xr/assets/demos/occlusion.html +251 -0
  113. package/skills/mudra-xr/assets/demos/rain.html +498 -0
  114. package/skills/mudra-xr/assets/demos/screenwiper.html +432 -0
  115. package/skills/mudra-xr/assets/demos/splash.html +519 -0
  116. package/skills/mudra-xr/assets/demos/webcam_gestures.html +253 -0
  117. package/skills/mudra-xr/assets/demos/xremoji.html +833 -0
  118. package/skills/mudra-xr/assets/demos/xrpoet.html +151 -0
  119. package/skills/mudra-xr/assets/samples/depthmap.html +266 -0
  120. package/skills/mudra-xr/assets/samples/depthmesh.html +100 -0
  121. package/skills/mudra-xr/assets/samples/game_rps.html +1099 -0
  122. package/skills/mudra-xr/assets/samples/gestures_custom.html +467 -0
  123. package/skills/mudra-xr/assets/samples/gestures_heuristic.html +256 -0
  124. package/skills/mudra-xr/assets/samples/lighting.html +163 -0
  125. package/skills/mudra-xr/assets/samples/mesh_detection.html +46 -0
  126. package/skills/mudra-xr/assets/samples/modelviewer.html +183 -0
  127. package/skills/mudra-xr/assets/samples/paint.html +133 -0
  128. package/skills/mudra-xr/assets/samples/planar-vst.html +45 -0
  129. package/skills/mudra-xr/assets/samples/reticle.html +95 -0
  130. package/skills/mudra-xr/assets/samples/skybox_agent.html +384 -0
  131. package/skills/mudra-xr/assets/samples/sound.html +390 -0
  132. package/skills/mudra-xr/assets/samples/ui.html +496 -0
  133. package/skills/mudra-xr/assets/samples/virtual-screens.html +808 -0
  134. package/skills/mudra-xr/assets/templates/0_basic.html +64 -0
  135. package/skills/mudra-xr/assets/templates/1_ui.html +107 -0
  136. package/skills/mudra-xr/assets/templates/2_hands.html +183 -0
  137. package/skills/mudra-xr/assets/templates/3_depth.html +89 -0
  138. package/skills/mudra-xr/assets/templates/4_stereo.html +84 -0
  139. package/skills/mudra-xr/assets/templates/5_camera.html +117 -0
  140. package/skills/mudra-xr/assets/templates/6_ai.html +154 -0
  141. package/skills/mudra-xr/assets/templates/7_ai_live.html +253 -0
  142. package/skills/mudra-xr/assets/templates/8_objects.html +95 -0
  143. package/skills/mudra-xr/assets/templates/9_xr-toggle.html +90 -0
  144. package/skills/mudra-xr/assets/templates/heuristic_hand_gestures.html +102 -0
  145. package/skills/mudra-xr/assets/templates/meshes.html +47 -0
  146. package/skills/mudra-xr/assets/templates/planes.html +47 -0
  147. package/skills/mudra-xr/assets/templates/uikit.html +187 -0
  148. package/skills/mudra-xr/references/agent_protocol.json +878 -0
  149. package/skills/mudra-xr/references/promt.md +1516 -0
@@ -0,0 +1,3200 @@
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>Mudra Duel v4 — Rock Paper Scissors</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f0f9ff;
10
+ --card: #ffffff;
11
+ --primary: #0d9488;
12
+ --accent: #14b8a6;
13
+ --text: #0f172a;
14
+ --text-secondary: #475569;
15
+ --success: #16a34a;
16
+ --warning: #ca8a04;
17
+ --error: #dc2626;
18
+ --pink: #e11d78;
19
+ --yellow: #d97706;
20
+ --purple: #7c3aed;
21
+ }
22
+
23
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24
+
25
+ body {
26
+ min-height: 100vh;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ font-family: Poppins, system-ui, sans-serif;
30
+ overflow-x: hidden;
31
+ }
32
+
33
+ .app {
34
+ width: min(1100px, 100%);
35
+ margin: 0 auto;
36
+ padding: 20px;
37
+ display: grid;
38
+ gap: 14px;
39
+ }
40
+
41
+ .card {
42
+ background: var(--card);
43
+ border: 1px solid rgba(13,148,136,0.18);
44
+ border-radius: 16px;
45
+ padding: 20px;
46
+ box-shadow: 0 4px 16px rgba(0,0,0,0.08);
47
+ }
48
+
49
+ h1, h2, h3, p { margin: 0; }
50
+ .title { font-size: 1.24rem; font-weight: 700; }
51
+ .muted { color: var(--text-secondary); margin-top: 6px; font-size: 0.92rem; }
52
+
53
+ .header-row {
54
+ display: flex; justify-content: space-between; align-items: center;
55
+ gap: 12px; flex-wrap: wrap; margin-top: 12px;
56
+ }
57
+ .status-row { display: flex; gap: 12px; flex-wrap: wrap; }
58
+ .status {
59
+ display: inline-flex; align-items: center; gap: 8px;
60
+ font-size: 0.88rem; padding: 7px 12px; border-radius: 999px;
61
+ border: 1px solid rgba(15,23,42,0.12);
62
+ background: rgba(15,23,42,0.02);
63
+ }
64
+ .dot {
65
+ width: 9px; height: 9px; border-radius: 50%;
66
+ background: var(--warning);
67
+ box-shadow: 0 0 0 4px rgba(234,179,8,0.15);
68
+ transition: background 150ms ease;
69
+ }
70
+ .dot.connected {
71
+ background: var(--success);
72
+ box-shadow: 0 0 0 4px rgba(34,197,94,0.18);
73
+ }
74
+
75
+ button, .btn {
76
+ border: none; border-radius: 10px; padding: 10px 18px;
77
+ font-weight: 700; cursor: pointer; font-size: 0.92rem;
78
+ background: var(--primary); color: #072f33;
79
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
80
+ }
81
+ button:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(119,234,233,0.25); }
82
+ button:active { transform: translateY(0); }
83
+ button:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
84
+ .btn-secondary {
85
+ background: transparent; color: var(--text);
86
+ border: 1px solid rgba(148,163,184,0.3); font-weight: 600;
87
+ }
88
+ .btn-danger { background: rgba(239,68,68,0.15); color: var(--error); border: 1px solid rgba(239,68,68,0.3); }
89
+ .btn-success { background: var(--success); color: #072f33; }
90
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; }
91
+
92
+ .screen { display: none; animation: fadeUp 400ms ease; }
93
+ .screen.active { display: block; }
94
+ @keyframes fadeUp {
95
+ from { opacity: 0; transform: translateY(16px); }
96
+ to { opacity: 1; transform: translateY(0); }
97
+ }
98
+
99
+ .logo {
100
+ font-size: 2.4rem; font-weight: 800; letter-spacing: -0.5px;
101
+ background: linear-gradient(90deg, var(--primary), var(--pink), var(--yellow));
102
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
103
+ background-clip: text;
104
+ }
105
+ .tagline { color: var(--text-secondary); font-size: 1rem; margin-top: 4px; }
106
+
107
+ .rules-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 28px 0; }
108
+ .rule-card {
109
+ background: rgba(255,255,255,0.03); border: 1px solid rgba(119,234,233,0.1);
110
+ border-radius: 14px; padding: 20px; text-align: center;
111
+ transition: border-color 200ms ease, transform 200ms ease;
112
+ }
113
+ .rule-card:hover { border-color: rgba(119,234,233,0.3); transform: translateY(-2px); }
114
+ .rule-icon { font-size: 2.8rem; margin-bottom: 8px; }
115
+ .rule-name { font-weight: 700; color: var(--primary); font-size: 1.05rem; }
116
+ .rule-beats { color: var(--text-secondary); font-size: 0.85rem; margin-top: 4px; }
117
+
118
+ .settings-grid { display: grid; gap: 12px; margin: 16px 0; }
119
+ .setting-row {
120
+ display: flex; justify-content: space-between; align-items: center;
121
+ padding: 10px 0; border-bottom: 1px solid rgba(148,163,184,0.1);
122
+ }
123
+ .setting-row:last-child { border-bottom: none; }
124
+ .setting-label { font-size: 0.92rem; }
125
+ select {
126
+ padding: 8px 14px; border-radius: 8px;
127
+ background: rgba(255,255,255,0.08); border: 1px solid rgba(148,163,184,0.2);
128
+ color: var(--text); font-size: 0.88rem; font-family: inherit;
129
+ }
130
+
131
+ .cal-progress { display: flex; justify-content: center; gap: 24px; margin: 20px 0; }
132
+ .cal-step {
133
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
134
+ opacity: 0.35; transition: opacity 300ms ease, transform 200ms ease;
135
+ }
136
+ .cal-step.active { opacity: 1; transform: scale(1.08); }
137
+ .cal-step.done { opacity: 1; }
138
+ .step-circle {
139
+ width: 52px; height: 52px; border-radius: 50%;
140
+ border: 2px solid rgba(148,163,184,0.25);
141
+ display: grid; place-items: center; font-size: 1.5rem;
142
+ background: rgba(0,0,0,0.3); transition: all 300ms ease;
143
+ }
144
+ .cal-step.active .step-circle {
145
+ border-color: var(--primary);
146
+ box-shadow: 0 0 20px rgba(119,234,233,0.25);
147
+ }
148
+ .cal-step.done .step-circle { background: var(--success); border-color: var(--success); }
149
+ .step-name { font-size: 0.78rem; color: var(--text-secondary); }
150
+
151
+ .cal-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
152
+ .gesture-prompt { text-align: center; padding: 30px 20px; }
153
+ .prompt-icon {
154
+ font-size: 5rem; margin-bottom: 12px;
155
+ animation: breathe 2s ease-in-out infinite;
156
+ }
157
+ @keyframes breathe {
158
+ 0%, 100% { transform: scale(1); opacity: 1; }
159
+ 50% { transform: scale(1.04); opacity: 0.85; }
160
+ }
161
+ .prompt-text { font-size: 1.3rem; font-weight: 700; }
162
+ .prompt-hint { color: var(--text-secondary); font-size: 0.88rem; margin-top: 6px; }
163
+
164
+ .sample-dots { display: flex; justify-content: center; gap: 8px; margin: 16px 0; }
165
+ .sdot {
166
+ width: 16px; height: 16px; border-radius: 50%;
167
+ background: rgba(255,255,255,0.12); transition: all 300ms ease;
168
+ }
169
+ .sdot.recorded { background: var(--success); box-shadow: 0 0 8px rgba(34,197,94,0.5); }
170
+ .sdot.recording { background: var(--pink); animation: rec-pulse 500ms infinite; }
171
+ @keyframes rec-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.35); } }
172
+
173
+ .sample-item {
174
+ display: flex; align-items: center; justify-content: space-between;
175
+ padding: 6px 10px; margin: 3px 0;
176
+ background: rgba(0,0,0,0.25); border-radius: 8px; font-size: 0.82rem;
177
+ }
178
+ .sample-info { display: flex; align-items: center; gap: 8px; }
179
+ .quality-tag { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
180
+ .quality-tag.excellent { background: var(--success); color: #000; }
181
+ .quality-tag.good { background: var(--primary); color: #000; }
182
+ .quality-tag.ok { background: var(--yellow); color: #000; }
183
+ .remove-btn {
184
+ background: transparent; border: none; color: var(--error);
185
+ cursor: pointer; padding: 2px 6px; font-size: 1rem; opacity: 0.6;
186
+ transition: opacity 150ms;
187
+ }
188
+ .remove-btn:hover { opacity: 1; transform: none; box-shadow: none; }
189
+
190
+ .record-btn { padding: 16px 44px; font-size: 1.1rem; border-radius: 50px; }
191
+ .record-btn.recording {
192
+ background: linear-gradient(90deg, var(--pink), #ff6b6b);
193
+ animation: rec-glow 1s infinite;
194
+ }
195
+ @keyframes rec-glow {
196
+ 0%,100% { box-shadow: 0 0 16px rgba(255,61,138,0.35); }
197
+ 50% { box-shadow: 0 0 32px rgba(255,61,138,0.7); }
198
+ }
199
+
200
+ .sensor-card {
201
+ background: rgba(0,0,0,0.3); border-radius: 12px; padding: 12px;
202
+ border: 1px solid rgba(148,163,184,0.1);
203
+ }
204
+ .sensor-header {
205
+ display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;
206
+ }
207
+ .sensor-title {
208
+ font-size: 0.78rem; color: var(--text-secondary);
209
+ text-transform: uppercase; letter-spacing: 1px;
210
+ }
211
+ .sensor-vals { display: flex; gap: 12px; font-family: 'Menlo', monospace; font-size: 0.82rem; }
212
+ .sv-x { color: #ff6b6b; } .sv-y { color: #4ecdc4; } .sv-z { color: #ffe66d; }
213
+ .sensor-canvas { width: 100%; height: 70px; background: rgba(0,0,0,0.2); border-radius: 8px; }
214
+ .snc-wave-canvas { width: 100%; height: 70px; border-radius: 6px; background: rgba(0,0,0,0.2); }
215
+ .snc-legend { display: flex; justify-content: center; gap: 14px; margin-top: 5px; font-size: 0.72rem; }
216
+ .snc-legend-item { display: flex; align-items: center; gap: 4px; }
217
+ .snc-legend-color { width: 12px; height: 3px; border-radius: 2px; }
218
+
219
+ .live-recog { text-align: center; padding: 10px; }
220
+ .live-icon { font-size: 2rem; margin-bottom: 4px; }
221
+ .live-scores {
222
+ display: flex; justify-content: space-around; margin-top: 8px;
223
+ font-size: 0.78rem; flex-wrap: wrap; gap: 4px;
224
+ }
225
+
226
+ /* Training progress */
227
+ .train-panel {
228
+ margin-top: 14px; padding: 12px; background: rgba(0,0,0,0.25);
229
+ border-radius: 10px; text-align: center; display: none;
230
+ }
231
+ .train-panel.active { display: block; }
232
+ .train-label { font-size: 0.82rem; color: var(--text-secondary); margin-bottom: 8px; }
233
+ .train-bar { height: 6px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; }
234
+ .train-fill {
235
+ height: 100%; width: 0%;
236
+ background: linear-gradient(90deg, var(--purple), var(--primary));
237
+ border-radius: 999px; transition: width 50ms linear;
238
+ }
239
+ .train-stats { font-size: 0.78rem; color: var(--primary); margin-top: 6px; font-weight: 600; }
240
+
241
+ .val-content { max-width: 520px; margin: 0 auto; text-align: center; }
242
+ .val-icon { font-size: 4.5rem; margin-bottom: 12px; }
243
+ .val-text { font-size: 1.4rem; font-weight: 700; }
244
+ .val-result { margin: 16px 0; font-size: 1.1rem; min-height: 28px; }
245
+
246
+ .detect-window {
247
+ background: rgba(0,0,0,0.4); border-radius: 14px;
248
+ padding: 14px; margin: 16px auto; max-width: 480px;
249
+ }
250
+ .detect-labels { display: flex; justify-content: space-between; font-size: 0.88rem; color: var(--text-secondary); }
251
+ .detect-bar {
252
+ height: 8px; background: rgba(255,255,255,0.08);
253
+ border-radius: 999px; overflow: hidden; margin-top: 8px;
254
+ }
255
+ .detect-fill {
256
+ height: 100%; width: 0%;
257
+ background: linear-gradient(90deg, var(--accent), var(--primary));
258
+ border-radius: 999px; transition: width 80ms linear;
259
+ }
260
+
261
+ .arena {
262
+ display: flex; justify-content: space-between; align-items: center;
263
+ max-width: 800px; margin: 0 auto; padding: 32px 16px;
264
+ }
265
+ .player-side { flex: 1; text-align: center; }
266
+ .player-label {
267
+ font-size: 1rem; color: var(--text-secondary); text-transform: uppercase;
268
+ letter-spacing: 2px; margin-bottom: 12px;
269
+ }
270
+ .player-gesture {
271
+ width: 160px; height: 160px; border-radius: 50%;
272
+ background: rgba(255,255,255,0.04); border: 3px solid rgba(148,163,184,0.2);
273
+ display: grid; place-items: center; font-size: 4.5rem;
274
+ margin: 0 auto 12px; transition: all 350ms ease;
275
+ }
276
+ .player-gesture.win { border-color: var(--success); box-shadow: 0 0 28px rgba(34,197,94,0.35); }
277
+ .player-gesture.lose { border-color: var(--error); box-shadow: 0 0 28px rgba(239,68,68,0.35); }
278
+ .player-gesture.draw { border-color: var(--yellow); box-shadow: 0 0 28px rgba(255,215,0,0.35); }
279
+ .player-name { font-size: 1rem; font-weight: 600; }
280
+
281
+ .vs-col { display: flex; flex-direction: column; align-items: center; padding: 0 24px; }
282
+ .vs-text {
283
+ font-size: 1.8rem; font-weight: 800;
284
+ background: linear-gradient(180deg, var(--primary), var(--pink));
285
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
286
+ }
287
+ .countdown-num {
288
+ font-size: 6rem; font-weight: 800;
289
+ background: linear-gradient(90deg, var(--primary), var(--pink));
290
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
291
+ animation: cd-pulse 900ms ease-in-out;
292
+ }
293
+ @keyframes cd-pulse {
294
+ 0% { transform: scale(1.4); opacity: 0; }
295
+ 50% { transform: scale(1); opacity: 1; }
296
+ 100% { transform: scale(0.85); opacity: 0.85; }
297
+ }
298
+
299
+ .game-status { font-size: 1.3rem; text-align: center; margin: 16px 0; min-height: 36px; }
300
+ .result-text {
301
+ font-size: 2.4rem; font-weight: 800; text-transform: uppercase;
302
+ animation: pop-in 400ms ease;
303
+ }
304
+ @keyframes pop-in { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } }
305
+ .result-text.win { color: var(--success); }
306
+ .result-text.lose { color: var(--error); }
307
+ .result-text.draw { color: var(--yellow); }
308
+
309
+ .score-row { display: flex; justify-content: center; gap: 48px; margin: 16px 0; }
310
+ .score-item { text-align: center; }
311
+ .score-label { font-size: 0.85rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1px; }
312
+ .score-value { font-size: 2.6rem; font-weight: 800; color: var(--primary); }
313
+
314
+ .final-result { font-size: 3.2rem; font-weight: 800; margin: 24px 0; text-align: center; }
315
+ .final-result.victory {
316
+ background: linear-gradient(90deg, var(--success), var(--primary));
317
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
318
+ }
319
+ .final-result.defeat { color: var(--error); }
320
+
321
+ .stats-grid {
322
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
323
+ gap: 14px; max-width: 600px; margin: 28px auto;
324
+ }
325
+ .stat-card {
326
+ background: rgba(255,255,255,0.04); border: 1px solid rgba(119,234,233,0.1);
327
+ border-radius: 12px; padding: 16px; text-align: center;
328
+ }
329
+ .stat-val { font-size: 1.8rem; font-weight: 700; color: var(--primary); }
330
+ .stat-lbl { font-size: 0.85rem; color: var(--text-secondary); margin-top: 4px; }
331
+
332
+ .gesture-usage { display: flex; justify-content: center; gap: 28px; margin: 24px 0; }
333
+ .usage-item { text-align: center; }
334
+ .usage-icon { font-size: 2.2rem; margin-bottom: 4px; }
335
+ .usage-count { font-size: 1.1rem; font-weight: 600; }
336
+
337
+ .modal-overlay {
338
+ position: fixed; inset: 0; background: rgba(0,0,0,0.8);
339
+ display: none; place-items: center; z-index: 100;
340
+ }
341
+ .modal-overlay.active { display: grid; }
342
+ .modal {
343
+ background: linear-gradient(180deg, #1c2428, var(--card));
344
+ border-radius: 18px; padding: 24px; max-width: 500px; width: 90%;
345
+ border: 1px solid rgba(119,234,233,0.18);
346
+ box-shadow: 0 20px 50px rgba(0,0,0,0.6);
347
+ }
348
+ .modal h3 { margin-bottom: 16px; font-size: 1.3rem; }
349
+ .modal-buttons { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
350
+
351
+ .toast {
352
+ position: fixed; bottom: 28px; left: 50%; z-index: 200;
353
+ transform: translateX(-50%) translateY(80px);
354
+ padding: 12px 24px; border-radius: 12px;
355
+ background: rgba(0,0,0,0.92); color: var(--text);
356
+ font-size: 0.92rem; border: 1px solid rgba(148,163,184,0.15);
357
+ transition: transform 300ms ease; pointer-events: none;
358
+ }
359
+ .toast.show { transform: translateX(-50%) translateY(0); }
360
+ .toast.success { border-left: 4px solid var(--success); }
361
+ .toast.error { border-left: 4px solid var(--error); }
362
+ .toast.info { border-left: 4px solid var(--primary); }
363
+
364
+ .folder-import-banner {
365
+ background: linear-gradient(135deg, rgba(139,92,246,0.12), rgba(99,102,241,0.10));
366
+ border: 1px solid rgba(139,92,246,0.3);
367
+ border-radius: 14px; padding: 14px 18px; margin-bottom: 14px;
368
+ display: none; animation: fadeSlideIn 0.4s ease;
369
+ }
370
+ .folder-import-banner h4 { margin: 0 0 6px; font-size: 0.95rem; color: var(--purple); }
371
+ .folder-import-banner p { margin: 0 0 10px; font-size: 0.84rem; color: var(--text-secondary); }
372
+ .folder-import-banner .file-list { margin: 0 0 10px; padding: 0; list-style: none; }
373
+ .folder-import-banner .file-list li {
374
+ font-size: 0.82rem; padding: 4px 8px; margin: 3px 0;
375
+ background: rgba(139,92,246,0.08); border-radius: 6px;
376
+ cursor: pointer; transition: background 0.15s;
377
+ }
378
+ .folder-import-banner .file-list li:hover { background: rgba(139,92,246,0.18); }
379
+ .folder-import-banner .file-list li.selected { background: rgba(139,92,246,0.25); border: 1px solid var(--purple); }
380
+ .folder-import-actions { display: flex; gap: 8px; }
381
+ @keyframes fadeSlideIn { from { opacity:0; transform: translateY(-8px); } to { opacity:1; transform: translateY(0); } }
382
+
383
+ .mudra-badge {
384
+ position: fixed; bottom: 12px; right: 12px;
385
+ font-size: 11px; color: var(--text-secondary);
386
+ opacity: 0.7; font-family: inherit; letter-spacing: 0.02em; pointer-events: none;
387
+ }
388
+
389
+ .fab {
390
+ position: fixed; bottom: 20px; width: 46px; height: 46px;
391
+ border-radius: 50%; border: none; cursor: pointer;
392
+ font-size: 1.1rem; font-weight: 700; z-index: 50;
393
+ display: grid; place-items: center; transition: transform 200ms ease; padding: 0;
394
+ }
395
+ .fab:hover { transform: scale(1.1); box-shadow: none; }
396
+ .fab-help { right: 20px; background: var(--primary); color: #072f33; }
397
+ .fab-debug { right: 76px; background: var(--purple); color: #fff; }
398
+
399
+ .debug-panel {
400
+ position: fixed; bottom: 78px; right: 20px; width: 280px;
401
+ background: rgba(0,0,0,0.92); border: 1px solid rgba(148,163,184,0.15);
402
+ border-radius: 12px; padding: 14px; font-family: 'Menlo', monospace;
403
+ font-size: 0.72rem; color: var(--text-secondary);
404
+ display: none; z-index: 100; max-height: 360px; overflow-y: auto;
405
+ }
406
+ .debug-panel.active { display: block; }
407
+ .debug-head { display: flex; justify-content: space-between; margin-bottom: 8px; }
408
+ .debug-title { color: var(--primary); font-weight: 700; }
409
+ .debug-close { background: transparent; border: none; color: var(--text-secondary); cursor: pointer; font-size: 0.8rem; padding: 0; }
410
+ .debug-close:hover { transform: none; box-shadow: none; }
411
+ .debug-sep { border: none; border-top: 1px solid rgba(148,163,184,0.12); margin: 8px 0; }
412
+
413
+ .preset-item {
414
+ display: flex; justify-content: space-between; align-items: center;
415
+ padding: 10px 14px; background: rgba(255,255,255,0.04);
416
+ border-radius: 10px; margin-bottom: 8px; cursor: pointer;
417
+ border: 1px solid transparent; transition: all 200ms ease;
418
+ }
419
+ .preset-item:hover { background: rgba(119,234,233,0.08); border-color: rgba(119,234,233,0.25); }
420
+ .preset-name { font-weight: 600; }
421
+ .preset-date { font-size: 0.72rem; color: var(--text-secondary); }
422
+ .preset-actions { display: flex; gap: 8px; align-items: center; }
423
+ .preset-load { color: var(--success); font-size: 0.78rem; }
424
+ .profile-item {
425
+ display: flex; justify-content: space-between; align-items: center;
426
+ padding: 10px 14px; background: rgba(255,255,255,0.04);
427
+ border-radius: 10px; margin-bottom: 8px; cursor: pointer;
428
+ border: 1px solid transparent; transition: all 200ms ease;
429
+ }
430
+ .profile-item:hover { background: rgba(119,234,233,0.08); border-color: rgba(119,234,233,0.25); }
431
+ .profile-name { font-weight: 600; font-size: 0.95rem; }
432
+ .profile-meta { font-size: 0.72rem; color: var(--text-secondary); }
433
+ .profile-play { color: var(--success); font-size: 0.82rem; font-weight: 600; }
434
+ .btn-danger-sm { background: transparent; border: 1px solid var(--error); color: var(--error); width:26px; height:26px; padding:0; border-radius:6px; font-size:0.9rem; display:grid; place-items:center; cursor:pointer; }
435
+ .btn-danger-sm:hover { background: var(--error); color: #fff; transform: none; box-shadow: none; }
436
+ .btn-edit-sm { background: transparent; border: 1px solid var(--primary); color: var(--primary); width:26px; height:26px; padding:0; border-radius:6px; font-size:0.8rem; display:grid; place-items:center; cursor:pointer; }
437
+ .btn-edit-sm:hover { background: rgba(119,234,233,0.15); transform: none; box-shadow: none; }
438
+ .hand-btn {
439
+ flex: 1; padding: 8px 12px; border-radius: 10px; font-size: 0.88rem; font-weight: 600;
440
+ background: rgba(255,255,255,0.04); border: 2px solid rgba(148,163,184,0.15);
441
+ color: var(--text-secondary); cursor: pointer; transition: all 200ms ease;
442
+ }
443
+ .hand-btn:hover { border-color: rgba(119,234,233,0.3); transform: none; box-shadow: none; }
444
+ .hand-btn.selected { border-color: var(--primary); color: var(--primary); background: rgba(119,234,233,0.08); }
445
+ .hand-btn[data-hand="left"] .hand-emoji { transform: scaleX(-1); display: inline-block; }
446
+
447
+ input[type="text"] {
448
+ width: 100%; padding: 10px 14px; border-radius: 8px;
449
+ background: rgba(255,255,255,0.08); border: 1px solid rgba(148,163,184,0.2);
450
+ color: var(--text); font-size: 0.95rem; font-family: inherit;
451
+ }
452
+
453
+ @media (max-width: 768px) {
454
+ .rules-grid { grid-template-columns: 1fr; }
455
+ .cal-layout { grid-template-columns: 1fr; }
456
+ .arena { flex-direction: column; gap: 16px; }
457
+ .vs-col { padding: 16px 0; }
458
+ .player-gesture { width: 120px; height: 120px; font-size: 3rem; }
459
+ .cal-progress { gap: 14px; }
460
+ .step-circle { width: 44px; height: 44px; font-size: 1.2rem; }
461
+ }
462
+
463
+ .pulse { animation: pulse-anim 150ms ease; }
464
+ @keyframes pulse-anim { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1); } }
465
+
466
+ /* Debug Mode */
467
+ .debug-gesture-btn {
468
+ flex: 1; padding: 14px 8px; border-radius: 12px; font-size: 1rem;
469
+ background: rgba(255,255,255,0.04); border: 2px solid rgba(148,163,184,0.15);
470
+ color: var(--text); cursor: pointer; transition: all 200ms ease;
471
+ }
472
+ .debug-gesture-btn:hover { border-color: rgba(119,234,233,0.4); background: rgba(119,234,233,0.08); transform: translateY(-2px); box-shadow: none; }
473
+ .debug-gesture-btn.correct { border-color: var(--success); background: rgba(34,197,94,0.12); }
474
+ .debug-gesture-btn.incorrect { border-color: var(--error); background: rgba(239,68,68,0.08); }
475
+ .debug-gesture-btn.selected-correct { border-color: var(--success); background: rgba(34,197,94,0.2); box-shadow: 0 0 16px rgba(34,197,94,0.3); }
476
+ .debug-prob-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 0.82rem; }
477
+ .debug-prob-label { width: 90px; text-align: left; display: flex; align-items: center; gap: 6px; }
478
+ .debug-prob-bar-bg { flex: 1; height: 8px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; }
479
+ .debug-prob-bar-fill { height: 100%; border-radius: 999px; transition: width 150ms ease; }
480
+ .debug-prob-pct { width: 40px; text-align: right; color: var(--primary); font-family: 'Menlo', monospace; font-size: 0.78rem; }
481
+ .debug-capture-recording { background: linear-gradient(90deg, var(--purple), var(--pink)) !important; color: #fff !important; }
482
+ @keyframes rec-glow { 0%,100% { box-shadow: 0 0 8px rgba(168,85,247,0.3); } 50% { box-shadow: 0 0 20px rgba(168,85,247,0.6); } }
483
+ .debug-capture-recording { animation: rec-glow 1s infinite; }
484
+ #debugReviewPanel { animation: fadeUp 300ms ease; }
485
+ .debug-stats-row { display: flex; justify-content: space-around; text-align: center; padding-top: 16px; border-top: 1px solid rgba(148,163,184,0.1); margin-top: 20px; }
486
+ .debug-stat-val { font-size: 1.4rem; font-weight: 700; }
487
+ .debug-stat-lbl { font-size: 0.78rem; color: var(--text-secondary); }
488
+ </style>
489
+ </head>
490
+ <body>
491
+ <a href="/gallery" style="position:fixed;top:12px;left:12px;z-index:9999;padding:6px 14px;background:rgba(0,0,0,0.6);color:#77eae9;text-decoration:none;font-size:13px;font-family:system-ui,sans-serif;border-radius:8px;backdrop-filter:blur(8px);border:1px solid rgba(119,234,233,0.2);transition:background 0.2s;"onmouseenter="this.style.background='rgba(0,0,0,0.8)'"onmouseleave="this.style.background='rgba(0,0,0,0.6)'">&larr; Gallery</a>
492
+ <main class="app">
493
+ <section class="card">
494
+ <h1 class="logo">MUDRA DUEL</h1>
495
+ <p class="tagline">Gesture-Powered Rock Paper Scissors</p>
496
+ <div class="header-row">
497
+ <div class="status-row">
498
+ <div class="status"><span class="dot" id="wsDot"></span><span id="wsText">Connecting...</span></div>
499
+ <div class="status"><span class="dot" id="deviceDot"></span><span id="deviceText">Device: Unknown</span></div>
500
+ <div class="status"><span class="dot" id="trainerDot"></span><span id="trainerText">Trainer: Checking...</span></div>
501
+ <div class="status"><span class="dot" id="storageDot"></span><span id="storageText">Storage: Checking...</span></div>
502
+ </div>
503
+ </div>
504
+ </section>
505
+
506
+ <!-- WELCOME -->
507
+ <div id="screen-welcome" class="screen active">
508
+ <section class="card" style="text-align:center;">
509
+ <div style="font-size:4rem; margin-bottom:8px;">&#x1F91C;&#x1F91B;</div>
510
+ <h2 style="font-size:1.6rem; margin-bottom:10px;">Ready to Duel?</h2>
511
+ <p class="muted" style="max-width:520px; margin:0 auto 24px;">
512
+ Use your Mudra Band to play Rock Paper Scissors with real gestures.
513
+ Calibrate 3 gestures, train a model with sklearn, then battle the AI.
514
+ </p>
515
+ <div class="rules-grid">
516
+ <div class="rule-card"><div class="rule-icon">&#x270A;</div><div class="rule-name">Rock</div><div class="rule-beats">Beats Scissors</div></div>
517
+ <div class="rule-card"><div class="rule-icon">&#x270B;</div><div class="rule-name">Paper</div><div class="rule-beats">Beats Rock</div></div>
518
+ <div class="rule-card"><div class="rule-icon">&#x270C;</div><div class="rule-name">Scissors</div><div class="rule-beats">Beats Paper</div></div>
519
+ </div>
520
+ <div class="card" style="max-width:400px; margin:0 auto 24px; text-align:left;">
521
+ <h3 style="margin-bottom:12px;">Game Settings</h3>
522
+ <div class="settings-grid">
523
+ <div class="setting-row"><span class="setting-label">Rounds to Win</span>
524
+ <select id="optRounds"><option value="2">Best of 3</option><option value="3" selected>Best of 5</option><option value="5">Best of 9</option><option value="0">Endless</option></select></div>
525
+ <div class="setting-row"><span class="setting-label">Detection Window</span>
526
+ <select id="optDetection"><option value="1000">1 second</option><option value="1500" selected>1.5 seconds</option><option value="2000">2 seconds</option></select></div>
527
+ <div class="setting-row"><span class="setting-label">Samples per Gesture</span>
528
+ <select id="optSamples"><option value="3">3 samples</option><option value="4">4 samples</option><option value="5" selected>5 samples</option><option value="6">6 samples</option></select></div>
529
+ </div>
530
+ </div>
531
+ <!-- New Calibration: profile name + hand -->
532
+ <div class="card" style="max-width:400px; margin:0 auto 16px; text-align:left;">
533
+ <h3 style="margin-bottom:12px;">New Calibration</h3>
534
+ <div style="display:flex; gap:8px; align-items:center; margin-bottom:10px;">
535
+ <input type="text" id="profileNameInput" placeholder="Profile name" style="flex:1;">
536
+ <button id="btnStartCal">Calibrate</button>
537
+ </div>
538
+ <div style="display:flex; gap:8px;">
539
+ <button class="hand-btn" id="btnHandLeft" data-hand="left">🫲 Left Hand</button>
540
+ <button class="hand-btn selected" id="btnHandRight" data-hand="right">🫱 Right Hand</button>
541
+ </div>
542
+ </div>
543
+
544
+ <!-- Folder profile files auto-detect banner -->
545
+ <div id="folderImportBanner" class="folder-import-banner" style="max-width:400px; margin:0 auto 14px;">
546
+ <h4>📂 Profile files found</h4>
547
+ <p id="folderImportDesc">Data files were found in the project folder. Select one to import:</p>
548
+ <ul class="file-list" id="folderFileList"></ul>
549
+ <div class="folder-import-actions">
550
+ <button class="btn-primary" id="btnFolderImport" style="font-size:0.82rem; padding:6px 16px;" disabled>Import Selected</button>
551
+ <button class="btn-secondary" id="btnFolderDismiss" style="font-size:0.82rem; padding:6px 12px;">Dismiss</button>
552
+ </div>
553
+ </div>
554
+
555
+ <!-- Saved Profiles: quick start -->
556
+ <div id="savedProfilesSection" class="card" style="max-width:400px; margin:0 auto 16px; text-align:left; display:none;">
557
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
558
+ <h3>Saved Profiles</h3>
559
+ <button id="btnPlayAll" class="btn-success" style="padding:6px 14px; font-size:0.85rem; display:none;">Play All Combined</button>
560
+ </div>
561
+ <div id="savedProfilesList" style="max-height:200px; overflow-y:auto;"></div>
562
+ </div>
563
+
564
+ <div class="actions" style="justify-content:center; gap:8px;">
565
+ <button class="btn-secondary" id="btnQuickPlay">Quick Play (Demo)</button>
566
+ <button class="btn-secondary" id="btnExportData" style="font-size:0.82rem;">Export Data</button>
567
+ <button class="btn-secondary" id="btnImportData" style="font-size:0.82rem;">Import Data</button>
568
+ <input type="file" id="importFileInput" accept=".json" style="display:none;">
569
+ </div>
570
+ </section>
571
+ </div>
572
+
573
+ <!-- CALIBRATION -->
574
+ <div id="screen-cal" class="screen">
575
+ <section class="card">
576
+ <div style="text-align:center; margin-bottom:16px;">
577
+ <h2 style="font-size:1.5rem;">Gesture Training</h2>
578
+ <p class="muted">Record samples, then train a model to recognize your gestures</p>
579
+ <div id="calProfileBadge" style="margin-top:8px; display:inline-block; padding:4px 14px; border-radius:20px; background:rgba(119,234,233,0.1); border:1px solid rgba(119,234,233,0.25); font-size:0.82rem; color:var(--primary);"></div>
580
+ </div>
581
+ <div class="cal-progress" id="calProgress"></div>
582
+ </section>
583
+
584
+ <div class="cal-layout">
585
+ <section class="card gesture-prompt">
586
+ <div class="prompt-icon" id="calIcon">&#x270A;</div>
587
+ <div class="prompt-text" id="calText">Make a fist (Rock)</div>
588
+ <div class="prompt-hint" id="calHint">Closed fist — also your ready position</div>
589
+ <div class="sample-dots" id="sampleDots"></div>
590
+ <div id="sampleList" style="margin:8px 0; max-height:110px; overflow-y:auto;"></div>
591
+ <div style="margin-top:12px;">
592
+ <button class="record-btn" id="btnRecord">Record Sample</button>
593
+ <button class="btn-secondary" id="btnAddMore" style="display:none; margin-left:8px;">+ Add More</button>
594
+ </div>
595
+ <p class="muted" style="margin-top:8px; font-size:0.82rem;">Click button, wait for countdown, then HOLD the gesture steady</p>
596
+
597
+ <!-- Training panel -->
598
+ <div class="train-panel" id="trainPanel">
599
+ <div class="train-label" id="trainLabel">Training neural network...</div>
600
+ <div class="train-bar"><div class="train-fill" id="trainFill"></div></div>
601
+ <div class="train-stats" id="trainStats"></div>
602
+ </div>
603
+
604
+ <div style="margin-top:16px; display:flex; gap:10px; justify-content:center; flex-wrap:wrap;">
605
+ <button class="btn-secondary" id="btnResetGesture">Reset Gesture</button>
606
+ <button id="btnNextGesture" disabled>Next Gesture</button>
607
+ </div>
608
+ <div id="trainNowRow" style="margin-top:12px; display:none; text-align:center;">
609
+ <button id="btnTrainNow" class="btn-success" style="width:100%;">🚀 Train Model & Play</button>
610
+ </div>
611
+ <div id="profileSavedNote" style="margin-top:12px; padding-top:12px; border-top:1px solid rgba(148,163,184,0.1); display:none; text-align:center; color:var(--success); font-size:0.85rem;">
612
+ ✓ Profile auto-saved
613
+ </div>
614
+ </section>
615
+
616
+ <section class="card" style="display:grid; gap:12px;">
617
+ <div class="sensor-card">
618
+ <div class="sensor-header">
619
+ <span class="sensor-title">Accelerometer</span>
620
+ <div class="sensor-vals"><span class="sv-x" id="calAccX">0.00</span><span class="sv-y" id="calAccY">0.00</span><span class="sv-z" id="calAccZ">0.00</span></div>
621
+ </div>
622
+ <canvas id="canvasAcc" class="sensor-canvas"></canvas>
623
+ </div>
624
+ <div class="sensor-card">
625
+ <div class="sensor-header">
626
+ <span class="sensor-title">Gyroscope</span>
627
+ <div class="sensor-vals"><span class="sv-x" id="calGyroX">0.00</span><span class="sv-y" id="calGyroY">0.00</span><span class="sv-z" id="calGyroZ">0.00</span></div>
628
+ </div>
629
+ <canvas id="canvasGyro" class="sensor-canvas"></canvas>
630
+ </div>
631
+ <div class="sensor-card">
632
+ <div class="sensor-header"><span class="sensor-title">SNC (Muscle Activity)</span></div>
633
+ <canvas id="canvasSnc" class="snc-wave-canvas"></canvas>
634
+ <div class="snc-legend">
635
+ <div class="snc-legend-item"><div class="snc-legend-color" style="background:#00d4ff;"></div><span>CH1</span></div>
636
+ <div class="snc-legend-item"><div class="snc-legend-color" style="background:#22c55e;"></div><span>CH2</span></div>
637
+ <div class="snc-legend-item"><div class="snc-legend-color" style="background:#ff6b6b;"></div><span>CH3</span></div>
638
+ </div>
639
+ </div>
640
+ <div class="sensor-card">
641
+ <div class="sensor-header"><span class="sensor-title">Live Recognition</span></div>
642
+ <div class="live-recog">
643
+ <div class="live-icon" id="liveIcon">-</div>
644
+ <div style="font-size:0.85rem; color:var(--text-secondary);">
645
+ Detected: <span id="liveName" style="color:var(--primary);">None</span>
646
+ </div>
647
+ <div class="live-scores">
648
+ <span>Rock: <span id="liveRock" style="color:var(--primary);">-</span></span>
649
+ <span>Paper: <span id="livePaper" style="color:var(--primary);">-</span></span>
650
+ <span>Scissors: <span id="liveScissors" style="color:var(--primary);">-</span></span>
651
+ </div>
652
+ </div>
653
+ </div>
654
+ <div style="text-align:center;">
655
+ <button class="btn-secondary" id="btnSkipCal" style="font-size:0.82rem;">Skip Calibration (Test Mode)</button>
656
+ </div>
657
+ </section>
658
+ </div>
659
+ </div>
660
+
661
+ <!-- VALIDATION -->
662
+ <div id="screen-val" class="screen">
663
+ <section class="card">
664
+ <div class="val-content">
665
+ <h2 style="font-size:1.5rem; margin-bottom:8px;">Gesture Validation</h2>
666
+ <p class="muted" style="margin-bottom:24px;">Perform each gesture when prompted to verify the trained model.</p>
667
+ <div class="val-icon" id="valIcon">&#x2753;</div>
668
+ <div class="val-text" id="valText">Perform: Rock</div>
669
+ <div class="val-result" id="valResult"></div>
670
+ <div class="detect-window">
671
+ <div class="detect-labels">
672
+ <span>Detected: <span id="valDetected">Waiting...</span></span>
673
+ <span>Confidence: <span id="valConf">0%</span></span>
674
+ </div>
675
+ <div class="detect-bar"><div class="detect-fill" id="valBar"></div></div>
676
+ </div>
677
+ <div class="actions" style="justify-content:center; margin-top:16px;">
678
+ <button class="btn-secondary" id="btnRecalibrate">Add Samples</button>
679
+ <button id="btnStartGame" disabled>Start Game!</button>
680
+ <button class="btn-success" id="btnStartAnyway" style="display:none;">Start Anyway</button>
681
+ </div>
682
+ <p id="valNote" class="muted" style="margin-top:8px; font-size:0.82rem; display:none;">Recognition may be less accurate with failed validation</p>
683
+ </div>
684
+ </section>
685
+ </div>
686
+
687
+ <!-- GAME -->
688
+ <div id="screen-game" class="screen">
689
+ <section class="card">
690
+ <div class="score-row">
691
+ <div class="score-item"><div class="score-label">You</div><div class="score-value" id="scorePlayer">0</div></div>
692
+ <div class="score-item"><div class="score-label">AI</div><div class="score-value" id="scoreAi">0</div></div>
693
+ </div>
694
+ <div class="arena">
695
+ <div class="player-side">
696
+ <div class="player-label">You</div>
697
+ <div class="player-gesture" id="pGesture">&#x2753;</div>
698
+ <div class="player-name" id="pName">Ready</div>
699
+ </div>
700
+ <div class="vs-col">
701
+ <div class="countdown-num" id="cdNum"></div>
702
+ <div class="vs-text" id="vsText">VS</div>
703
+ </div>
704
+ <div class="player-side">
705
+ <div class="player-label">AI</div>
706
+ <div class="player-gesture" id="aGesture">&#x2753;</div>
707
+ <div class="player-name" id="aName">Ready</div>
708
+ </div>
709
+ </div>
710
+ <div class="game-status" id="gameStatus">Get ready for the next round!</div>
711
+ <div class="detect-window" id="gameDetectWin" style="display:none;">
712
+ <div class="detect-labels"><span>Make your gesture!</span><span id="gameDetectTime">1.5s</span></div>
713
+ <div class="detect-bar"><div class="detect-fill" id="gameDetectBar"></div></div>
714
+ </div>
715
+ <!-- Correction panel (hidden until user clicks Wrong?) -->
716
+ <div id="gameCorrectionPanel" style="display:none; margin-top:12px; padding:14px; background:rgba(0,0,0,0.25); border-radius:12px; text-align:center;">
717
+ <div style="font-size:0.88rem; color:var(--text-secondary); margin-bottom:10px;">What gesture were you making?</div>
718
+ <div style="display:flex; gap:10px; justify-content:center;">
719
+ <button class="debug-gesture-btn game-correct-btn" data-gesture="rock"><div style="font-size:1.8rem;">&#x270A;</div><div style="font-size:0.78rem; margin-top:2px;">Rock</div></button>
720
+ <button class="debug-gesture-btn game-correct-btn" data-gesture="paper"><div style="font-size:1.8rem;">&#x270B;</div><div style="font-size:0.78rem; margin-top:2px;">Paper</div></button>
721
+ <button class="debug-gesture-btn game-correct-btn" data-gesture="scissors"><div style="font-size:1.8rem;">&#x270C;</div><div style="font-size:0.78rem; margin-top:2px;">Scissors</div></button>
722
+ </div>
723
+ </div>
724
+ <div class="actions" style="justify-content:center; margin-top:20px;">
725
+ <button id="btnNextRound" style="display:none;">Next Round</button>
726
+ <button class="btn-secondary" id="btnWrongGesture" style="display:none;">Wrong?</button>
727
+ <button class="btn-secondary" id="btnQuitGame">Quit Game</button>
728
+ </div>
729
+ </section>
730
+ </div>
731
+
732
+ <!-- RESULTS -->
733
+ <div id="screen-results" class="screen">
734
+ <section class="card" style="text-align:center;">
735
+ <h2 style="font-size:1.2rem; color:var(--text-secondary);">Match Complete!</h2>
736
+ <div class="final-result" id="finalResult">VICTORY!</div>
737
+ <div class="score-row">
738
+ <div class="score-item"><div class="score-label">Your Score</div><div class="score-value" id="finalPlayer">0</div></div>
739
+ <div class="score-item"><div class="score-label">AI Score</div><div class="score-value" id="finalAi">0</div></div>
740
+ </div>
741
+ <div class="stats-grid">
742
+ <div class="stat-card"><div class="stat-val" id="statRounds">0</div><div class="stat-lbl">Rounds Played</div></div>
743
+ <div class="stat-card"><div class="stat-val" id="statAccuracy">0%</div><div class="stat-lbl">Gesture Accuracy</div></div>
744
+ <div class="stat-card"><div class="stat-val" id="statAvgTime">0ms</div><div class="stat-lbl">Avg Response</div></div>
745
+ <div class="stat-card"><div class="stat-val" id="statStreak">0</div><div class="stat-lbl">Best Streak</div></div>
746
+ </div>
747
+ <h3 style="margin-bottom:12px;">Gesture Usage</h3>
748
+ <div class="gesture-usage">
749
+ <div class="usage-item"><div class="usage-icon">&#x270A;</div><div class="usage-count" id="usageRock">0</div></div>
750
+ <div class="usage-item"><div class="usage-icon">&#x270B;</div><div class="usage-count" id="usagePaper">0</div></div>
751
+ <div class="usage-item"><div class="usage-icon">&#x270C;</div><div class="usage-count" id="usageScissors">0</div></div>
752
+ </div>
753
+ <div class="actions" style="justify-content:center; margin-top:28px;">
754
+ <button id="btnPlayAgain">Play Again</button>
755
+ <button class="btn-secondary" id="btnRecalResults">Add Samples</button>
756
+ <button class="btn-secondary" id="btnMainMenu">Main Menu</button>
757
+ </div>
758
+ </section>
759
+ </div>
760
+
761
+ <!-- DEBUG MODE -->
762
+ <div id="screen-debug" class="screen">
763
+ <section class="card">
764
+ <div style="text-align:center; margin-bottom:16px;">
765
+ <h2 style="font-size:1.5rem;">Debug Mode</h2>
766
+ <p class="muted">Test predictions, correct mistakes, improve your model</p>
767
+ <div id="debugProfileBadge" style="margin-top:8px; display:inline-block; padding:4px 14px; border-radius:20px; background:rgba(168,85,247,0.1); border:1px solid rgba(168,85,247,0.25); font-size:0.82rem; color:var(--purple);"></div>
768
+ </div>
769
+ </section>
770
+
771
+ <div class="cal-layout">
772
+ <!-- LEFT: Prediction + Controls -->
773
+ <section class="card" style="text-align:center;">
774
+ <!-- Live prediction -->
775
+ <div id="debugLivePrediction">
776
+ <div id="debugLiveIcon" style="font-size:5rem; margin-bottom:8px;">?</div>
777
+ <div style="font-size:1.3rem; font-weight:700;"><span id="debugLiveName">Waiting for data...</span></div>
778
+ <div style="font-size:0.88rem; color:var(--text-secondary); margin-top:4px;">
779
+ Confidence: <span id="debugLiveConf" style="color:var(--primary);">0%</span>
780
+ </div>
781
+ <div id="debugProbBars" style="margin:16px 0;"></div>
782
+ </div>
783
+
784
+ <!-- Capture button -->
785
+ <div id="debugCaptureRow" style="margin:16px 0;">
786
+ <button id="btnDebugCapture" style="width:100%; padding:14px; font-size:1.05rem;">Capture Gesture</button>
787
+ <p class="muted" style="margin-top:8px; font-size:0.82rem;">Hold your gesture, then click (or press Space)</p>
788
+ </div>
789
+
790
+ <!-- Review panel (hidden) -->
791
+ <div id="debugReviewPanel" style="display:none;">
792
+ <div style="padding:16px; background:rgba(0,0,0,0.25); border-radius:12px; margin-bottom:12px;">
793
+ <div style="font-size:0.85rem; color:var(--text-secondary); margin-bottom:8px;">Model predicted:</div>
794
+ <div style="font-size:3rem;" id="debugReviewIcon">?</div>
795
+ <div style="font-size:1.2rem; font-weight:700;" id="debugReviewName">Unknown</div>
796
+ <div style="font-size:0.85rem; color:var(--text-secondary); margin-top:4px;">Confidence: <span id="debugReviewConf">0%</span></div>
797
+ </div>
798
+ <div style="font-size:0.92rem; color:var(--text-secondary); margin-bottom:10px;">What gesture were you actually making?</div>
799
+ <div style="display:flex; gap:10px; justify-content:center;">
800
+ <button class="debug-gesture-btn" data-gesture="rock"><div style="font-size:2rem;">&#x270A;</div><div style="font-size:0.82rem; margin-top:4px;">Rock</div></button>
801
+ <button class="debug-gesture-btn" data-gesture="paper"><div style="font-size:2rem;">&#x270B;</div><div style="font-size:0.82rem; margin-top:4px;">Paper</div></button>
802
+ <button class="debug-gesture-btn" data-gesture="scissors"><div style="font-size:2rem;">&#x270C;</div><div style="font-size:0.82rem; margin-top:4px;">Scissors</div></button>
803
+ </div>
804
+ </div>
805
+
806
+ <!-- Stats -->
807
+ <div class="debug-stats-row">
808
+ <div><div class="debug-stat-val" style="color:var(--primary);" id="debugStatCaptures">0</div><div class="debug-stat-lbl">Captures</div></div>
809
+ <div><div class="debug-stat-val" style="color:var(--success);" id="debugStatCorrect">0</div><div class="debug-stat-lbl">Correct</div></div>
810
+ <div><div class="debug-stat-val" style="color:var(--warning);" id="debugStatAccuracy">-</div><div class="debug-stat-lbl">Accuracy</div></div>
811
+ <div><div class="debug-stat-val" style="color:var(--purple);" id="debugStatNew">0</div><div class="debug-stat-lbl">New Samples</div></div>
812
+ </div>
813
+
814
+ <!-- Retrain button (hidden) -->
815
+ <div id="debugRetrainRow" style="display:none; margin-top:14px;">
816
+ <button id="btnDebugRetrain" class="btn-success" style="width:100%;">Retrain with <span id="debugRetrainCount">0</span> new samples</button>
817
+ </div>
818
+
819
+ <!-- Training progress -->
820
+ <div class="train-panel" id="debugTrainPanel">
821
+ <div class="train-label" id="debugTrainLabel">Training...</div>
822
+ <div class="train-bar"><div class="train-fill" id="debugTrainFill"></div></div>
823
+ <div class="train-stats" id="debugTrainStats"></div>
824
+ </div>
825
+
826
+ <!-- Navigation -->
827
+ <div style="margin-top:16px; display:flex; gap:10px; justify-content:center;">
828
+ <button class="btn-secondary" id="btnDebugBack">Back to Menu</button>
829
+ <button id="btnDebugPlay">Play Game</button>
830
+ </div>
831
+ </section>
832
+
833
+ <!-- RIGHT: Sensor data -->
834
+ <section class="card" style="display:grid; gap:12px;">
835
+ <div class="sensor-card">
836
+ <div class="sensor-header">
837
+ <span class="sensor-title">Accelerometer</span>
838
+ <div class="sensor-vals">
839
+ <span class="sv-x" id="dbgModeAccX">0.00</span>
840
+ <span class="sv-y" id="dbgModeAccY">0.00</span>
841
+ <span class="sv-z" id="dbgModeAccZ">0.00</span>
842
+ </div>
843
+ </div>
844
+ <canvas id="canvasAccDebug" class="sensor-canvas"></canvas>
845
+ </div>
846
+ <div class="sensor-card">
847
+ <div class="sensor-header">
848
+ <span class="sensor-title">Gyroscope</span>
849
+ <div class="sensor-vals">
850
+ <span class="sv-x" id="dbgModeGyroX">0.00</span>
851
+ <span class="sv-y" id="dbgModeGyroY">0.00</span>
852
+ <span class="sv-z" id="dbgModeGyroZ">0.00</span>
853
+ </div>
854
+ </div>
855
+ <canvas id="canvasGyroDebug" class="sensor-canvas"></canvas>
856
+ </div>
857
+ <div class="sensor-card">
858
+ <div class="sensor-header"><span class="sensor-title">SNC (Muscle Activity)</span></div>
859
+ <canvas id="canvasSncDebug" class="snc-wave-canvas"></canvas>
860
+ <div class="snc-legend">
861
+ <div class="snc-legend-item"><div class="snc-legend-color" style="background:#00d4ff;"></div><span>CH1</span></div>
862
+ <div class="snc-legend-item"><div class="snc-legend-color" style="background:#22c55e;"></div><span>CH2</span></div>
863
+ <div class="snc-legend-item"><div class="snc-legend-color" style="background:#ff6b6b;"></div><span>CH3</span></div>
864
+ </div>
865
+ </div>
866
+ <div class="sensor-card" style="text-align:center;">
867
+ <div class="sensor-header"><span class="sensor-title">Training Data</span></div>
868
+ <div style="display:flex; justify-content:space-around; padding:8px 0;">
869
+ <div><div style="font-size:1.5rem;">&#x270A;</div><div style="font-size:0.82rem; color:var(--text-secondary);">Rock</div><div style="font-size:1rem; font-weight:700; color:var(--primary);" id="debugSamplesRock">0</div></div>
870
+ <div><div style="font-size:1.5rem;">&#x270B;</div><div style="font-size:0.82rem; color:var(--text-secondary);">Paper</div><div style="font-size:1rem; font-weight:700; color:var(--primary);" id="debugSamplesPaper">0</div></div>
871
+ <div><div style="font-size:1.5rem;">&#x270C;</div><div style="font-size:0.82rem; color:var(--text-secondary);">Scissors</div><div style="font-size:1rem; font-weight:700; color:var(--primary);" id="debugSamplesScissors">0</div></div>
872
+ </div>
873
+ </div>
874
+ </section>
875
+ </div>
876
+ </div>
877
+ </main>
878
+
879
+ <div class="toast" id="toast"></div>
880
+
881
+ <!-- Help Modal -->
882
+ <div class="modal-overlay" id="helpModal"><div class="modal">
883
+ <h3>How to Play</h3>
884
+ <div style="color:var(--text-secondary); line-height:1.6; font-size:0.92rem;">
885
+ <h4 style="color:var(--text); margin:12px 0 6px;">With Mudra Band:</h4>
886
+ <ol style="padding-left:18px;">
887
+ <li>Connect your Mudra Band</li>
888
+ <li>Click "Start Calibration"</li>
889
+ <li>Record 3-5 samples per gesture (Rock, Paper, Scissors)</li>
890
+ <li>Model trains automatically after all gestures</li>
891
+ <li>Validate, then play!</li>
892
+ </ol>
893
+ <h4 style="color:var(--text); margin:12px 0 6px;">Demo Mode:</h4>
894
+ <ol style="padding-left:18px;">
895
+ <li>Click "Quick Play (Demo)"</li>
896
+ <li>During detection: <strong>R</strong>=Rock, <strong>P</strong>=Paper, <strong>S</strong>=Scissors</li>
897
+ </ol>
898
+ <h4 style="color:var(--text); margin:12px 0 6px;">Keyboard:</h4>
899
+ <ul style="padding-left:18px;">
900
+ <li><strong>Space</strong> &mdash; Record gesture during calibration</li>
901
+ <li><strong>Enter</strong> &mdash; Next round</li>
902
+ <li><strong>?</strong> &mdash; Show this help</li>
903
+ </ul>
904
+ </div>
905
+ <div class="modal-buttons"><button id="btnCloseHelp">Got it!</button></div>
906
+ </div></div>
907
+
908
+ <!-- Presets Modal -->
909
+ <div class="modal-overlay" id="presetsModal"><div class="modal" style="max-width:540px;">
910
+ <h3>Calibration Presets</h3>
911
+ <div id="presetsList" style="max-height:280px; overflow-y:auto; margin:12px 0;">
912
+ <div style="text-align:center; color:var(--text-secondary); padding:16px;">No presets saved yet</div>
913
+ </div>
914
+ <div class="modal-buttons"><button class="btn-secondary" id="btnClosePresets">Cancel</button></div>
915
+ </div></div>
916
+
917
+ <!-- Save Preset Modal -->
918
+ <div class="modal-overlay" id="savePresetModal"><div class="modal">
919
+ <h3>Save Calibration Preset</h3>
920
+ <div style="margin:16px 0;">
921
+ <label style="display:block; margin-bottom:6px; color:var(--text-secondary); font-size:0.88rem;">Preset Name</label>
922
+ <input type="text" id="presetNameInput" placeholder="e.g., My Gestures, Left Hand...">
923
+ </div>
924
+ <div class="modal-buttons">
925
+ <button class="btn-secondary" id="btnCloseSavePreset">Cancel</button>
926
+ <button id="btnDoSavePreset">Save Preset</button>
927
+ </div>
928
+ </div></div>
929
+
930
+ <!-- Debug Panel -->
931
+ <div class="debug-panel" id="debugPanel">
932
+ <div class="debug-head"><span class="debug-title">NN Debug</span><button class="debug-close" id="btnCloseDebug">X</button></div>
933
+ <div>Rock: <span id="dbgRock">-</span></div>
934
+ <div>Paper: <span id="dbgPaper">-</span></div>
935
+ <div>Scissors: <span id="dbgScissors">-</span></div>
936
+ <hr class="debug-sep">
937
+ <div>Detected: <span id="dbgDetected" style="color:var(--success);">-</span></div>
938
+ <div>Confidence: <span id="dbgConf">-</span></div>
939
+ <hr class="debug-sep">
940
+ <div>Model: <span id="dbgModel">not trained</span></div>
941
+ <div>Train Acc: <span id="dbgTrainAcc">-</span></div>
942
+ <hr class="debug-sep">
943
+ <div style="margin-top:6px;"><strong>Readings:</strong></div>
944
+ <div>Acc: <span id="dbgAcc">-</span></div>
945
+ <div>Gyro: <span id="dbgGyro">-</span></div>
946
+ <div>SNC: <span id="dbgSnc">-</span></div>
947
+ </div>
948
+
949
+ <button class="fab fab-debug" id="fabDebug">D</button>
950
+ <button class="fab fab-help" id="fabHelp">?</button>
951
+ <div class="mudra-badge">Created by Mudra</div>
952
+
953
+ <script>
954
+ // ============================================================
955
+ // MUDRA DUEL v2 — Neural Network Gesture Recognition
956
+ // Signals: imu_acc + imu_gyro + snc + gesture
957
+ // 3 gestures: rock, paper, scissors (no idle)
958
+ // ============================================================
959
+
960
+ const WS_URL = "ws://127.0.0.1:8766";
961
+ const SNC_BUF = 500;
962
+ const GESTURES = ["rock", "paper", "scissors"];
963
+ const ICONS = { rock: "\u{270A}", paper: "\u{270B}", scissors: "\u{270C}" };
964
+ const INFO = {
965
+ rock: { name: "Rock", hint: "Closed fist — thumb outside or tucked.", icon: "\u{270A}" },
966
+ paper: { name: "Paper", hint: "Open hand, ALL fingers fully extended and spread wide.", icon: "\u{270B}" },
967
+ scissors: { name: "Scissors", hint: "Index + middle finger in V shape, other fingers CLOSED.", icon: "\u{270C}" }
968
+ };
969
+
970
+ let ws, reconnTimer;
971
+
972
+ const S = {
973
+ wsConnected: false, deviceConnected: false,
974
+ curGestIdx: 0,
975
+ gestureData: { rock: { samples: [] }, paper: { samples: [] }, scissors: { samples: [] } },
976
+ isRecording: false,
977
+ recBuf: { imu: [], snc: [] },
978
+ samplesReq: 5,
979
+ accHist: { x:[], y:[], z:[] }, gyroHist: { x:[], y:[], z:[] },
980
+ sncBufs: [[], [], []],
981
+ curAcc: [0,0,0], curGyro: [0,0,0], curSnc: [0,0,0],
982
+ imuSeq: 0, sncSeq: 0, // incremented on each new WebSocket reading
983
+ inferBuf: { imu: [], snc: [] }, // rolling buffer for live inference (~1.5s)
984
+ pScore: 0, aScore: 0, roundsToWin: 3, detectionMs: 1500,
985
+ isPlaying: false, roundHistory: [],
986
+ usage: { rock:0, paper:0, scissors:0 },
987
+ respTimes: [], streak: 0, bestStreak: 0,
988
+ totalDetect: 0, successDetect: 0,
989
+ valIdx: 0, valPassed: 0,
990
+ quickMode: false,
991
+ modelTrained: false,
992
+ trainAccuracy: 0,
993
+ profileName: "",
994
+ hand: "right",
995
+ currentProfileId: null,
996
+ // Debug mode
997
+ debugCaptures: 0,
998
+ debugCorrect: 0,
999
+ debugNewSamples: 0,
1000
+ debugIsCapturing: false,
1001
+ debugCapturedResult: null,
1002
+ debugPhase: 'live' // 'live' | 'capturing' | 'review'
1003
+ };
1004
+
1005
+ let quickPlayGesture = null;
1006
+
1007
+ // ============ MODEL (sklearn-trained, JS inference) ============
1008
+ // Features: acc(mean3+std3+mag2=8) + gyro(mean3+std3=6) + snc(mean3+std3+rms3+min3+max3+range3=18) = 32
1009
+ const FEAT_DIM = 32;
1010
+ const TRAINER_URL = "http://127.0.0.1:8767";
1011
+
1012
+ const Model = {
1013
+ type: null, // 'mlp' or 'rf'
1014
+ scaler: null, // { mean: number[], std: number[] }
1015
+ mlp: null, // { layers: number[][][], biases: number[][], activation: string }
1016
+ rf: null, // { trees: TreeNode[][], n_classes: number }
1017
+ meta: null, // { model_type, cv_results, best_cv_score, train_accuracy }
1018
+
1019
+ // Scale input using the same StandardScaler as training
1020
+ scale(input) {
1021
+ if (!this.scaler) return Array.from(input);
1022
+ return Array.from(input).map((v, i) =>
1023
+ (v - this.scaler.mean[i]) / (this.scaler.std[i] + 1e-8)
1024
+ );
1025
+ },
1026
+
1027
+ // MLP forward pass — supports arbitrary layer sizes from sklearn
1028
+ mlpForward(x) {
1029
+ let cur = x;
1030
+ for (let l = 0; l < this.mlp.layers.length; l++) {
1031
+ const W = this.mlp.layers[l]; // [prevSize][nextSize]
1032
+ const b = this.mlp.biases[l]; // [nextSize]
1033
+ const next = new Array(b.length).fill(0);
1034
+ for (let j = 0; j < b.length; j++) {
1035
+ let sum = b[j];
1036
+ for (let i = 0; i < cur.length; i++) sum += cur[i] * W[i][j];
1037
+ next[j] = sum;
1038
+ }
1039
+ // Apply activation (ReLU for hidden layers, none for output)
1040
+ if (l < this.mlp.layers.length - 1) {
1041
+ const act = this.mlp.activation || 'relu';
1042
+ if (act === 'relu') {
1043
+ for (let j = 0; j < next.length; j++) next[j] = Math.max(0, next[j]);
1044
+ } else if (act === 'tanh') {
1045
+ for (let j = 0; j < next.length; j++) next[j] = Math.tanh(next[j]);
1046
+ } else if (act === 'logistic') {
1047
+ for (let j = 0; j < next.length; j++) next[j] = 1 / (1 + Math.exp(-next[j]));
1048
+ }
1049
+ }
1050
+ cur = next;
1051
+ }
1052
+ return softmax(cur);
1053
+ },
1054
+
1055
+ // Random Forest inference — traverse all trees, average probabilities
1056
+ rfForward(x) {
1057
+ const nClasses = this.rf.n_classes || 3;
1058
+ const avgProbs = new Array(nClasses).fill(0);
1059
+ for (const tree of this.rf.trees) {
1060
+ let nodeIdx = 0;
1061
+ while (tree[nodeIdx].left !== -1) {
1062
+ const node = tree[nodeIdx];
1063
+ nodeIdx = x[node.feature] <= node.threshold ? node.left : node.right;
1064
+ }
1065
+ const probs = tree[nodeIdx].probs;
1066
+ for (let c = 0; c < nClasses; c++) avgProbs[c] += probs[c];
1067
+ }
1068
+ const n = this.rf.trees.length;
1069
+ for (let c = 0; c < nClasses; c++) avgProbs[c] /= n;
1070
+ return avgProbs;
1071
+ },
1072
+
1073
+ predict(input) {
1074
+ const x = this.scale(input);
1075
+ let probs;
1076
+ if (this.type === 'mlp') probs = this.mlpForward(x);
1077
+ else if (this.type === 'rf') probs = this.rfForward(x);
1078
+ else return { gesture: null, confidence: 0, probs: [0.33, 0.33, 0.33] };
1079
+
1080
+ let bestIdx = 0;
1081
+ for (let i = 1; i < probs.length; i++) { if (probs[i] > probs[bestIdx]) bestIdx = i; }
1082
+ return { gesture: GESTURES[bestIdx], confidence: probs[bestIdx], probs };
1083
+ },
1084
+
1085
+ // Load model from trainer response
1086
+ load(resp) {
1087
+ this.type = resp.inference;
1088
+ this.scaler = resp.scaler || null;
1089
+ this.meta = {
1090
+ model_type: resp.model_type,
1091
+ cv_results: resp.cv_results,
1092
+ best_cv_score: resp.best_cv_score,
1093
+ train_accuracy: resp.train_accuracy
1094
+ };
1095
+ if (resp.inference === 'mlp' && resp.mlp) {
1096
+ this.mlp = resp.mlp;
1097
+ this.rf = null;
1098
+ } else if (resp.inference === 'rf' && resp.rf) {
1099
+ this.rf = resp.rf;
1100
+ this.mlp = null;
1101
+ }
1102
+ },
1103
+
1104
+ serialize() {
1105
+ return { type: this.type, scaler: this.scaler, mlp: this.mlp, rf: this.rf, meta: this.meta };
1106
+ },
1107
+
1108
+ deserialize(data) {
1109
+ this.type = data.type;
1110
+ this.scaler = data.scaler;
1111
+ this.mlp = data.mlp || null;
1112
+ this.rf = data.rf || null;
1113
+ this.meta = data.meta || null;
1114
+ },
1115
+
1116
+ clear() {
1117
+ this.type = null; this.scaler = null; this.mlp = null; this.rf = null; this.meta = null;
1118
+ }
1119
+ };
1120
+
1121
+ function softmax(logits) {
1122
+ const max = Math.max(...logits);
1123
+ const exps = logits.map(l => Math.exp(l - max));
1124
+ const sum = exps.reduce((a, b) => a + b, 0);
1125
+ return exps.map(e => e / sum);
1126
+ }
1127
+
1128
+ // Fallback: in-browser MLP training (no Python server needed)
1129
+ const FallbackNN = {
1130
+ train(features, labels, nClasses, onProgress) {
1131
+ const nFeats = features[0].length;
1132
+ const h1Size = 32, h2Size = 16;
1133
+ const w1 = xavierInit(nFeats, h1Size), b1 = new Float64Array(h1Size);
1134
+ const w2 = xavierInit(h1Size, h2Size), b2 = new Float64Array(h2Size);
1135
+ const w3 = xavierInit(h2Size, nClasses), b3 = new Float64Array(nClasses);
1136
+
1137
+ // Compute normalization
1138
+ const mean = new Float64Array(nFeats), std = new Float64Array(nFeats);
1139
+ for (const f of features) { for (let i = 0; i < nFeats; i++) mean[i] += f[i]; }
1140
+ for (let i = 0; i < nFeats; i++) mean[i] /= features.length;
1141
+ for (const f of features) { for (let i = 0; i < nFeats; i++) std[i] += (f[i] - mean[i])**2; }
1142
+ for (let i = 0; i < nFeats; i++) std[i] = Math.sqrt(std[i] / features.length);
1143
+
1144
+ // Augment
1145
+ const augFeats = [], augLabels = [];
1146
+ for (let s = 0; s < features.length; s++) {
1147
+ augFeats.push(features[s]); augLabels.push(labels[s]);
1148
+ for (let a = 0; a < 25; a++) {
1149
+ const noisy = features[s].map((v, i) => v + (Math.random()+Math.random()+Math.random()-1.5)*0.33*(Math.abs(v)*0.05+0.02));
1150
+ augFeats.push(noisy); augLabels.push(labels[s]);
1151
+ }
1152
+ }
1153
+
1154
+ const normalize = (f) => f.map((v, i) => (v - mean[i]) / (std[i] + 1e-8));
1155
+ const epochs = 400;
1156
+
1157
+ for (let ep = 0; ep < epochs; ep++) {
1158
+ // Shuffle
1159
+ for (let i = augFeats.length - 1; i > 0; i--) {
1160
+ const j = Math.floor(Math.random() * (i + 1));
1161
+ [augFeats[i], augFeats[j]] = [augFeats[j], augFeats[i]];
1162
+ [augLabels[i], augLabels[j]] = [augLabels[j], augLabels[i]];
1163
+ }
1164
+ const lr = 0.05 * (1 - ep / epochs * 0.8);
1165
+ for (let s = 0; s < augFeats.length; s++) {
1166
+ const x = normalize(augFeats[s]);
1167
+ // Forward
1168
+ const h1 = new Float64Array(h1Size);
1169
+ for (let j = 0; j < h1Size; j++) { let sum = b1[j]; for (let i = 0; i < nFeats; i++) sum += x[i] * w1[i*h1Size+j]; h1[j] = Math.max(0, sum); }
1170
+ const h2 = new Float64Array(h2Size);
1171
+ for (let j = 0; j < h2Size; j++) { let sum = b2[j]; for (let i = 0; i < h1Size; i++) sum += h1[i] * w2[i*h2Size+j]; h2[j] = Math.max(0, sum); }
1172
+ const logits = new Float64Array(nClasses);
1173
+ for (let j = 0; j < nClasses; j++) { let sum = b3[j]; for (let i = 0; i < h2Size; i++) sum += h2[i] * w3[i*nClasses+j]; logits[j] = sum; }
1174
+ const probs = softmax(Array.from(logits));
1175
+ // Backprop
1176
+ const dL = probs.map((p, j) => p - (j === augLabels[s] ? 1 : 0));
1177
+ const dH2 = new Float64Array(h2Size);
1178
+ for (let i = 0; i < h2Size; i++) { let sum = 0; for (let j = 0; j < nClasses; j++) { w3[i*nClasses+j] -= lr*h2[i]*dL[j]; sum += w3[i*nClasses+j]*dL[j]; } dH2[i] = h2[i]>0?sum:0; }
1179
+ for (let j = 0; j < nClasses; j++) b3[j] -= lr*dL[j];
1180
+ const dH1 = new Float64Array(h1Size);
1181
+ for (let i = 0; i < h1Size; i++) { let sum = 0; for (let j = 0; j < h2Size; j++) { w2[i*h2Size+j] -= lr*h1[i]*dH2[j]; sum += w2[i*h2Size+j]*dH2[j]; } dH1[i] = h1[i]>0?sum:0; }
1182
+ for (let j = 0; j < h2Size; j++) b2[j] -= lr*dH2[j];
1183
+ for (let i = 0; i < nFeats; i++) { for (let j = 0; j < h1Size; j++) w1[i*h1Size+j] -= lr*x[i]*dH1[j]; }
1184
+ for (let j = 0; j < h1Size; j++) b1[j] -= lr*dH1[j];
1185
+ }
1186
+ if (ep % 10 === 0 && onProgress) onProgress(ep, epochs);
1187
+ }
1188
+
1189
+ // Convert to sklearn-like format for Model.load()
1190
+ const layers = [[], []];
1191
+ // w1: flat[nFeats*h1Size] → [nFeats][h1Size]
1192
+ for (let i = 0; i < nFeats; i++) { layers[0].push([]); for (let j = 0; j < h1Size; j++) layers[0][i].push(w1[i*h1Size+j]); }
1193
+ // w2: flat → [h1Size][h2Size]
1194
+ for (let i = 0; i < h1Size; i++) { layers[0+1] = layers[1] || []; }
1195
+ layers[1] = [];
1196
+ for (let i = 0; i < h1Size; i++) { layers[1].push([]); for (let j = 0; j < h2Size; j++) layers[1][i].push(w2[i*h2Size+j]); }
1197
+ // w3: flat → [h2Size][nClasses]
1198
+ layers[2] = [];
1199
+ for (let i = 0; i < h2Size; i++) { layers[2].push([]); for (let j = 0; j < nClasses; j++) layers[2][i].push(w3[i*nClasses+j]); }
1200
+
1201
+ return {
1202
+ inference: 'mlp',
1203
+ model_type: 'mlp_fallback',
1204
+ scaler: { mean: Array.from(mean), std: Array.from(std) },
1205
+ mlp: { layers, biases: [Array.from(b1), Array.from(b2), Array.from(b3)], activation: 'relu' },
1206
+ cv_results: { mlp_fallback: { mean: 0, std: 0 } },
1207
+ best_cv_score: 0,
1208
+ train_accuracy: 0
1209
+ };
1210
+ }
1211
+ };
1212
+
1213
+ function xavierInit(fanIn, fanOut) {
1214
+ const limit = Math.sqrt(6 / (fanIn + fanOut));
1215
+ const w = new Float64Array(fanIn * fanOut);
1216
+ for (let i = 0; i < w.length; i++) w[i] = (Math.random() * 2 - 1) * limit;
1217
+ return w;
1218
+ }
1219
+
1220
+ // ============ FEATURE EXTRACTION ============
1221
+
1222
+ function extractFeatures(accData, gyroData, sncData) {
1223
+ const f = new Float64Array(FEAT_DIM);
1224
+ let idx = 0;
1225
+
1226
+ // Acc: mean(3) + std(3) + mag_mean(1) + mag_std(1) = 8
1227
+ const accS = stats3D(accData);
1228
+ f[idx++] = accS.mean[0]; f[idx++] = accS.mean[1]; f[idx++] = accS.mean[2];
1229
+ f[idx++] = accS.std[0]; f[idx++] = accS.std[1]; f[idx++] = accS.std[2];
1230
+ // Magnitude (captures gravity direction regardless of coordinate frame)
1231
+ const mags = (accData||[]).map(d => Math.sqrt((d[0]||0)**2 + (d[1]||0)**2 + (d[2]||0)**2));
1232
+ const magMean = mags.length ? mags.reduce((a,b) => a+b, 0) / mags.length : 0;
1233
+ const magStd = mags.length > 1 ? Math.sqrt(mags.reduce((a,v) => a + (v-magMean)**2, 0) / mags.length) : 0;
1234
+ f[idx++] = magMean; f[idx++] = magStd;
1235
+
1236
+ // Gyro: mean(3) + std(3) = 6
1237
+ const gyroS = stats3D(gyroData);
1238
+ f[idx++] = gyroS.mean[0]; f[idx++] = gyroS.mean[1]; f[idx++] = gyroS.mean[2];
1239
+ f[idx++] = gyroS.std[0]; f[idx++] = gyroS.std[1]; f[idx++] = gyroS.std[2];
1240
+
1241
+ // SNC: mean(3) + std(3) + rms(3) + min(3) + max(3) + range(3) = 18
1242
+ // SNC is the KEY signal for hand pose — extract rich features
1243
+ const sncS = statsRich3D(sncData);
1244
+ f[idx++] = sncS.mean[0]; f[idx++] = sncS.mean[1]; f[idx++] = sncS.mean[2];
1245
+ f[idx++] = sncS.std[0]; f[idx++] = sncS.std[1]; f[idx++] = sncS.std[2];
1246
+ f[idx++] = sncS.rms[0]; f[idx++] = sncS.rms[1]; f[idx++] = sncS.rms[2];
1247
+ f[idx++] = sncS.min[0]; f[idx++] = sncS.min[1]; f[idx++] = sncS.min[2];
1248
+ f[idx++] = sncS.max[0]; f[idx++] = sncS.max[1]; f[idx++] = sncS.max[2];
1249
+ f[idx++] = sncS.max[0] - sncS.min[0]; f[idx++] = sncS.max[1] - sncS.min[1]; f[idx++] = sncS.max[2] - sncS.min[2];
1250
+
1251
+ return f;
1252
+ }
1253
+
1254
+ // Unified extraction from calibration sample
1255
+ function extractSampleFeatures(sample) {
1256
+ const accData = sample.imu_data.accelerometer;
1257
+ const gyroData = sample.imu_data.gyroscope;
1258
+ const sncData = sample.snc_data.channels;
1259
+ return extractFeatures(accData, gyroData, sncData);
1260
+ }
1261
+
1262
+ // Unified extraction from live buffer { imu: [{acc, gyro}], snc: [[c1,c2,c3]] }
1263
+ function extractLiveFeatures(buffer) {
1264
+ const accData = buffer.imu.map(d => d.acc);
1265
+ const gyroData = buffer.imu.map(d => d.gyro);
1266
+ const sncData = buffer.snc;
1267
+ return extractFeatures(accData, gyroData, sncData);
1268
+ }
1269
+
1270
+ function stats3D(data) {
1271
+ const r = { mean: [0,0,0], std: [0,0,0] };
1272
+ if (!data || data.length === 0) return r;
1273
+ const n = data.length;
1274
+ for (const d of data) {
1275
+ r.mean[0] += (d[0]||0); r.mean[1] += (d[1]||0); r.mean[2] += (d[2]||0);
1276
+ }
1277
+ r.mean[0] /= n; r.mean[1] /= n; r.mean[2] /= n;
1278
+ for (const d of data) {
1279
+ r.std[0] += ((d[0]||0) - r.mean[0])**2;
1280
+ r.std[1] += ((d[1]||0) - r.mean[1])**2;
1281
+ r.std[2] += ((d[2]||0) - r.mean[2])**2;
1282
+ }
1283
+ r.std[0] = Math.sqrt(r.std[0] / n);
1284
+ r.std[1] = Math.sqrt(r.std[1] / n);
1285
+ r.std[2] = Math.sqrt(r.std[2] / n);
1286
+ return r;
1287
+ }
1288
+
1289
+ function statsRich3D(data) {
1290
+ const r = { mean: [0,0,0], std: [0,0,0], rms: [0,0,0], min: [Infinity,Infinity,Infinity], max: [-Infinity,-Infinity,-Infinity] };
1291
+ if (!data || data.length === 0) {
1292
+ r.min = [0,0,0]; r.max = [0,0,0]; return r;
1293
+ }
1294
+ const n = data.length;
1295
+ for (const d of data) {
1296
+ for (let c = 0; c < 3; c++) {
1297
+ const v = d[c]||0;
1298
+ r.mean[c] += v;
1299
+ r.rms[c] += v * v;
1300
+ if (v < r.min[c]) r.min[c] = v;
1301
+ if (v > r.max[c]) r.max[c] = v;
1302
+ }
1303
+ }
1304
+ for (let c = 0; c < 3; c++) {
1305
+ r.mean[c] /= n;
1306
+ r.rms[c] = Math.sqrt(r.rms[c] / n);
1307
+ }
1308
+ for (const d of data) {
1309
+ for (let c = 0; c < 3; c++) {
1310
+ r.std[c] += ((d[c]||0) - r.mean[c])**2;
1311
+ }
1312
+ }
1313
+ for (let c = 0; c < 3; c++) r.std[c] = Math.sqrt(r.std[c] / n);
1314
+ return r;
1315
+ }
1316
+
1317
+ // ============ MODEL TRAINING ============
1318
+ async function trainModel(elPrefix = 'train') {
1319
+ const panel = document.getElementById(elPrefix + "Panel");
1320
+ const fill = document.getElementById(elPrefix + "Fill");
1321
+ const label = document.getElementById(elPrefix + "Label");
1322
+ const stats = document.getElementById(elPrefix + "Stats");
1323
+
1324
+ panel.classList.add("active");
1325
+ label.textContent = "Preparing training data...";
1326
+ fill.style.width = "0%";
1327
+ stats.textContent = "";
1328
+
1329
+ await sleep(50);
1330
+
1331
+ // 1. Extract features from calibration samples
1332
+ const features = [], labels = [];
1333
+ GESTURES.forEach((g, classIdx) => {
1334
+ S.gestureData[g].samples.forEach(sample => {
1335
+ features.push(Array.from(extractSampleFeatures(sample)));
1336
+ labels.push(classIdx);
1337
+ });
1338
+ });
1339
+
1340
+ if (features.length < 3) {
1341
+ label.textContent = "Not enough samples!";
1342
+ stats.textContent = "Need at least 1 sample per gesture";
1343
+ return false;
1344
+ }
1345
+
1346
+ // 2. Try scikit-learn server first, fallback to in-browser
1347
+ let result;
1348
+ try {
1349
+ label.textContent = "Connecting to sklearn trainer...";
1350
+ fill.style.width = "10%";
1351
+ await sleep(50);
1352
+
1353
+ const resp = await fetch(`${TRAINER_URL}/train`, {
1354
+ method: 'POST',
1355
+ headers: { 'Content-Type': 'application/json' },
1356
+ body: JSON.stringify({ features, labels, gesture_names: GESTURES })
1357
+ });
1358
+
1359
+ if (!resp.ok) throw new Error(`Server error: ${resp.status}`);
1360
+ result = await resp.json();
1361
+ if (result.error) throw new Error(result.error);
1362
+
1363
+ label.textContent = `sklearn: ${result.model_type} selected`;
1364
+ fill.style.width = "80%";
1365
+ stats.textContent = `CV accuracy: ${(result.best_cv_score*100).toFixed(1)}% · Train: ${(result.train_accuracy*100).toFixed(1)}%`;
1366
+
1367
+ // Show model comparison
1368
+ if (result.cv_results) {
1369
+ const best = Object.entries(result.cv_results)
1370
+ .sort((a,b) => b[1].mean - a[1].mean)
1371
+ .map(([name, r]) => `${name}: ${(r.mean*100).toFixed(0)}%`)
1372
+ .join(' · ');
1373
+ console.log("Model comparison:", best);
1374
+ }
1375
+
1376
+ } catch (e) {
1377
+ console.log("sklearn server unavailable, using in-browser fallback:", e.message);
1378
+ label.textContent = "Training in-browser (no sklearn server)...";
1379
+ fill.style.width = "15%";
1380
+ stats.textContent = "Run mudra_trainer.py for better models";
1381
+ await sleep(50);
1382
+
1383
+ result = FallbackNN.train(features, labels, GESTURES.length, (ep, total) => {
1384
+ fill.style.width = `${15 + (ep/total)*80}%`;
1385
+ stats.textContent = `In-browser MLP · Epoch ${ep}/${total}`;
1386
+ });
1387
+ }
1388
+
1389
+ // 3. Load the trained model
1390
+ Model.load(result);
1391
+ S.modelTrained = true;
1392
+ S.trainAccuracy = result.train_accuracy || 0;
1393
+
1394
+ label.textContent = "Training complete!";
1395
+ const modelDesc = Model.type === 'rf' ? 'Random Forest' : `MLP (${result.model_type})`;
1396
+ stats.textContent = `${modelDesc} · CV: ${((result.best_cv_score||0)*100).toFixed(0)}% · Train: ${((result.train_accuracy||0)*100).toFixed(0)}% · ${features.length} samples`;
1397
+ fill.style.width = "100%";
1398
+
1399
+ setEl("dbgModel", `${Model.type} (${result.model_type})`);
1400
+ setEl("dbgTrainAcc", `${((result.train_accuracy||0)*100).toFixed(0)}%`);
1401
+
1402
+ // Auto-save profile (don't let save failure prevent game from starting)
1403
+ try { autoSaveProfile(); } catch (e) {
1404
+ console.error("Auto-save failed:", e);
1405
+ toast("⚠ Couldn't save profile (storage may be full)", "warning");
1406
+ }
1407
+
1408
+ await sleep(500);
1409
+ return true;
1410
+ }
1411
+
1412
+ // Predict from live buffer using trained model
1413
+ function predictGesture(buffer) {
1414
+ if (!S.modelTrained || !Model.type) return { gesture: null, confidence: 0, probs: [0.33, 0.33, 0.33] };
1415
+ if (!buffer || buffer.imu.length < 10) return { gesture: null, confidence: 0, probs: [0.33, 0.33, 0.33] };
1416
+
1417
+ const features = extractLiveFeatures(buffer);
1418
+ const result = Model.predict(Array.from(features));
1419
+
1420
+ if (result.confidence < 0.45) return { gesture: null, confidence: result.confidence, probs: result.probs };
1421
+ return result;
1422
+ }
1423
+
1424
+ // ============ CONNECTION ============
1425
+ function connect() {
1426
+ ws = new WebSocket(WS_URL);
1427
+ ws.onopen = () => { S.wsConnected = true; updateStatus(); subscribeSignals(); };
1428
+ ws.onclose = () => { S.wsConnected = false; updateStatus(); scheduleReconnect(); };
1429
+ ws.onerror = () => { S.wsConnected = false; updateStatus(); };
1430
+ ws.onmessage = (e) => {
1431
+ let msg; try { msg = JSON.parse(e.data); } catch { return; }
1432
+ if (msg.type === "connection_status") { S.deviceConnected = msg.data?.status === "connected"; updateStatus(); return; }
1433
+ if (msg.type === "imu_acc") handleImu("acc", msg.data);
1434
+ if (msg.type === "imu_gyro") handleImu("gyro", msg.data);
1435
+ if (msg.type === "snc") handleSnc(msg.data);
1436
+ if (msg.type === "gesture") handleMudraGesture(msg.data);
1437
+ };
1438
+ }
1439
+
1440
+ function subscribeSignals() {
1441
+ ["imu_acc", "imu_gyro", "snc", "gesture"].forEach(signal => send({ command: "subscribe", signal }));
1442
+ }
1443
+
1444
+ function send(p) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(p)); }
1445
+ function scheduleReconnect() { clearTimeout(reconnTimer); reconnTimer = setTimeout(connect, 1200); }
1446
+ function updateStatus() {
1447
+ document.getElementById("wsDot").classList.toggle("connected", S.wsConnected);
1448
+ document.getElementById("wsText").textContent = S.wsConnected ? "Connected" : "Disconnected";
1449
+ document.getElementById("deviceDot").classList.toggle("connected", S.deviceConnected);
1450
+ document.getElementById("deviceText").textContent = S.deviceConnected ? "Device: Ready" : "Device: Not Found";
1451
+ }
1452
+
1453
+ // Check sklearn trainer server availability
1454
+ let trainerAvailable = false;
1455
+ async function checkTrainer() {
1456
+ try {
1457
+ const resp = await fetch(`${TRAINER_URL}/health`, { signal: AbortSignal.timeout(2000) });
1458
+ trainerAvailable = resp.ok;
1459
+ } catch { trainerAvailable = false; }
1460
+ document.getElementById("trainerDot").classList.toggle("connected", trainerAvailable);
1461
+ document.getElementById("trainerText").textContent = trainerAvailable
1462
+ ? "Trainer: sklearn ready" : "Trainer: offline (in-browser fallback)";
1463
+ }
1464
+ checkTrainer();
1465
+ setInterval(checkTrainer, 10000);
1466
+
1467
+ // ============ SIGNAL HANDLERS ============
1468
+ function handleImu(type, data) {
1469
+ const vals = Array.isArray(data?.values) ? data.values.map(Number) : [0,0,0];
1470
+ if (type === "acc") {
1471
+ S.curAcc = vals; S.imuSeq++; pushHist(S.accHist, vals[0], vals[1], vals[2], 50);
1472
+ setEl("calAccX", vals[0].toFixed(2)); setEl("calAccY", vals[1].toFixed(2)); setEl("calAccZ", vals[2].toFixed(2));
1473
+ } else {
1474
+ S.curGyro = vals; S.imuSeq++; pushHist(S.gyroHist, vals[0], vals[1], vals[2], 50);
1475
+ setEl("calGyroX", vals[0].toFixed(2)); setEl("calGyroY", vals[1].toFixed(2)); setEl("calGyroZ", vals[2].toFixed(2));
1476
+ }
1477
+ if (S.isRecording) S.recBuf.imu.push({ timestamp: Date.now(), type, values: [...vals] });
1478
+ }
1479
+
1480
+ function handleSnc(data) {
1481
+ const channels = Array.isArray(data?.values) && data.values.length === 3 ? data.values : [[0],[0],[0]];
1482
+ for (let i = 0; i < 3; i++) {
1483
+ const ch = Array.isArray(channels[i]) ? channels[i] : [0];
1484
+ S.sncBufs[i].push(...ch);
1485
+ if (S.sncBufs[i].length > SNC_BUF) S.sncBufs[i].splice(0, S.sncBufs[i].length - SNC_BUF);
1486
+ }
1487
+ S.curSnc = S.sncBufs.map(buf => Number(buf[buf.length - 1] || 0));
1488
+ S.sncSeq++;
1489
+ drawSncWave();
1490
+ if (S.isRecording) {
1491
+ // Record ALL samples from this batch, not just the latest
1492
+ const batchLen = Math.max(...channels.map(ch => Array.isArray(ch) ? ch.length : 1));
1493
+ for (let s = 0; s < batchLen; s++) {
1494
+ S.recBuf.snc.push({
1495
+ timestamp: Date.now(),
1496
+ values: [
1497
+ Number(Array.isArray(channels[0]) ? (channels[0][s] ?? channels[0][channels[0].length-1]) : 0),
1498
+ Number(Array.isArray(channels[1]) ? (channels[1][s] ?? channels[1][channels[1].length-1]) : 0),
1499
+ Number(Array.isArray(channels[2]) ? (channels[2][s] ?? channels[2][channels[2].length-1]) : 0)
1500
+ ]
1501
+ });
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ function handleMudraGesture(data) { console.log("Mudra gesture:", data); }
1507
+ function pushHist(h, x, y, z, max) { h.x.push(x); h.y.push(y); h.z.push(z); if (h.x.length > max) { h.x.shift(); h.y.shift(); h.z.shift(); } }
1508
+ function setEl(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
1509
+
1510
+ // ============ SCREENS ============
1511
+ function showScreen(id) {
1512
+ document.querySelectorAll(".screen").forEach(s => s.classList.remove("active"));
1513
+ document.getElementById("screen-" + id).classList.add("active");
1514
+ if (id === "welcome") updateProfilesUI();
1515
+ if (id === "debug") startDebugSensorViz();
1516
+ }
1517
+ function openModal(id) { document.getElementById(id).classList.add("active"); }
1518
+ function closeModal(id) { document.getElementById(id).classList.remove("active"); }
1519
+
1520
+ // ============ CALIBRATION ============
1521
+ function startCalibration() {
1522
+ const nameInput = document.getElementById("profileNameInput");
1523
+ const name = nameInput.value.trim();
1524
+ if (!name) { toast("Enter a profile name first", "error"); nameInput.focus(); return; }
1525
+
1526
+ if (!S.wsConnected) connect();
1527
+ S.profileName = name;
1528
+ S.hand = document.querySelector(".hand-btn.selected")?.dataset.hand || "right";
1529
+ S.samplesReq = parseInt(document.getElementById("optSamples").value);
1530
+ S.roundsToWin = parseInt(document.getElementById("optRounds").value);
1531
+ S.detectionMs = parseInt(document.getElementById("optDetection").value);
1532
+ S.gestureData = { rock: { samples: [] }, paper: { samples: [] }, scissors: { samples: [] } };
1533
+ S.curGestIdx = 0;
1534
+ S.modelTrained = false;
1535
+ Model.clear();
1536
+ document.getElementById("trainPanel").classList.remove("active");
1537
+ showScreen("cal");
1538
+ updateCalUI();
1539
+ startSensorViz();
1540
+ }
1541
+
1542
+ function updateCalUI() {
1543
+ const g = GESTURES[S.curGestIdx];
1544
+ const info = INFO[g];
1545
+ const handIcon = S.hand === 'left' ? '🫲' : '🫱';
1546
+ document.getElementById("calProfileBadge").textContent = `${handIcon} ${S.profileName || 'Untitled'} · ${S.hand} hand`;
1547
+ document.getElementById("calIcon").textContent = info.icon;
1548
+ document.getElementById("calText").textContent = `Make: ${info.name}`;
1549
+ document.getElementById("calHint").textContent = info.hint;
1550
+
1551
+ // Progress steps — mark as done if gesture already has enough samples
1552
+ document.getElementById("calProgress").innerHTML = GESTURES.map((gn, i) => {
1553
+ const hasSamples = S.gestureData[gn].samples.length >= S.samplesReq;
1554
+ const cls = i === S.curGestIdx ? "cal-step active" : (i < S.curGestIdx || hasSamples) ? "cal-step done" : "cal-step";
1555
+ const count = S.gestureData[gn].samples.length;
1556
+ return `<div class="${cls}"><div class="step-circle">${ICONS[gn]}</div><div class="step-name">${INFO[gn].name}${count ? ` (${count})` : ''}</div></div>`;
1557
+ }).join("");
1558
+
1559
+ const samples = S.gestureData[g].samples;
1560
+ const dots = document.getElementById("sampleDots");
1561
+ dots.innerHTML = "";
1562
+ for (let i = 0; i < S.samplesReq; i++) {
1563
+ const d = document.createElement("div"); d.className = "sdot";
1564
+ if (i < samples.length) {
1565
+ d.classList.add("recorded");
1566
+ const stab = samples[i].stability || 0.5;
1567
+ if (stab > 0.7) { d.style.background = "var(--success)"; d.style.boxShadow = "0 0 8px rgba(34,197,94,0.5)"; }
1568
+ else if (stab > 0.5) { d.style.background = "var(--primary)"; }
1569
+ else { d.style.background = "var(--yellow)"; }
1570
+ }
1571
+ dots.appendChild(d);
1572
+ }
1573
+
1574
+ const list = document.getElementById("sampleList");
1575
+ list.innerHTML = samples.map((s, i) => {
1576
+ const stab = s.stability || 0.5;
1577
+ const qc = stab > 0.7 ? "excellent" : stab > 0.5 ? "good" : "ok";
1578
+ const qt = stab > 0.7 ? "Excellent" : stab > 0.5 ? "Good" : "OK";
1579
+ return `<div class="sample-item"><div class="sample-info"><span>Sample ${i+1}</span><span class="quality-tag ${qc}">${qt} (${(stab*100).toFixed(0)}%)</span></div><button class="remove-btn" onclick="removeSample('${g}',${i})">&times;</button></div>`;
1580
+ }).join("");
1581
+
1582
+ document.getElementById("btnAddMore").style.display = samples.length >= S.samplesReq ? "inline-block" : "none";
1583
+
1584
+ const nextBtn = document.getElementById("btnNextGesture");
1585
+ nextBtn.disabled = samples.length < S.samplesReq;
1586
+ nextBtn.textContent = S.curGestIdx === GESTURES.length - 1 ? "Train Model & Validate" : "Next Gesture";
1587
+
1588
+ // Show "Train & Play" button when all gestures have enough samples
1589
+ const allDone = GESTURES.every(g => S.gestureData[g].samples.length >= S.samplesReq);
1590
+ document.getElementById("trainNowRow").style.display = allDone ? "block" : "none";
1591
+ document.getElementById("profileSavedNote").style.display = (allDone && S.modelTrained) ? "block" : "none";
1592
+ }
1593
+
1594
+ async function doRecord() {
1595
+ const btn = document.getElementById("btnRecord");
1596
+ if (btn.disabled) return;
1597
+ const g = GESTURES[S.curGestIdx];
1598
+ const curLen = S.gestureData[g].samples.length;
1599
+ const dots = document.querySelectorAll(".sdot");
1600
+
1601
+ btn.disabled = true; btn.textContent = "Get ready...";
1602
+ for (let i = 2; i >= 1; i--) {
1603
+ btn.textContent = `Hold gesture in ${i}...`;
1604
+ Sfx.playCountdown(i);
1605
+ await sleep(800);
1606
+ }
1607
+ btn.textContent = "HOLD STILL!"; btn.classList.add("recording");
1608
+ Sfx.playShoot();
1609
+ if (dots[curLen]) dots[curLen].classList.add("recording");
1610
+
1611
+ await sleep(300);
1612
+ S.isRecording = true; S.recBuf = { imu: [], snc: [] };
1613
+ await sleep(1500);
1614
+ S.isRecording = false;
1615
+ btn.textContent = "Record Sample"; btn.classList.remove("recording"); btn.disabled = false;
1616
+ Sfx.playRecordStop();
1617
+
1618
+ if (S.recBuf.imu.length >= 10 || S.recBuf.snc.length >= 10) {
1619
+ const stab = calcStability(S.recBuf);
1620
+ if (stab < 0.3) { toast(`Too much movement! (${(stab*100).toFixed(0)}%)`, "error"); updateCalUI(); return; }
1621
+ S.gestureData[g].samples.push({
1622
+ timestamp: new Date().toISOString(),
1623
+ stability: stab,
1624
+ imu_data: {
1625
+ accelerometer: S.recBuf.imu.filter(d => d.type === "acc").map(d => d.values),
1626
+ gyroscope: S.recBuf.imu.filter(d => d.type === "gyro").map(d => d.values)
1627
+ },
1628
+ snc_data: { channels: S.recBuf.snc.map(d => d.values) }
1629
+ });
1630
+ const qt = stab > 0.7 ? "Excellent!" : stab > 0.5 ? "Good" : "OK";
1631
+ toast(`Sample ${S.gestureData[g].samples.length} recorded! ${qt}`, "success");
1632
+ } else {
1633
+ toast("Not enough sensor data. Check connection.", "error");
1634
+ }
1635
+ updateCalUI();
1636
+ }
1637
+
1638
+ function calcStability(buf) {
1639
+ const accD = buf.imu.filter(d => d.type === "acc").map(d => d.values);
1640
+ const gyroD = buf.imu.filter(d => d.type === "gyro").map(d => d.values);
1641
+ const sncD = buf.snc.map(d => d.values);
1642
+ if (accD.length < 5) return 0.5;
1643
+ const accVar = variance3D(accD);
1644
+ const gyroVar = gyroD.length >= 5 ? variance3D(gyroD) : 0;
1645
+ const sncVar = sncD.length >= 5 ? variance3D(sncD) : 0;
1646
+ return clamp01(Math.exp(-accVar/2)*0.3 + Math.exp(-gyroVar/50)*0.5 + Math.exp(-sncVar/0.1)*0.2);
1647
+ }
1648
+
1649
+ function variance3D(data) {
1650
+ if (data.length < 2) return 0;
1651
+ const mean = [0, 0, 0];
1652
+ data.forEach(d => { mean[0] += (d[0]||0)/data.length; mean[1] += (d[1]||0)/data.length; mean[2] += (d[2]||0)/data.length; });
1653
+ let v = 0;
1654
+ data.forEach(d => { v += ((d[0]||0) - mean[0])**2 + ((d[1]||0) - mean[1])**2 + ((d[2]||0) - mean[2])**2; });
1655
+ return v / data.length;
1656
+ }
1657
+
1658
+ function removeSample(g, idx) {
1659
+ if (S.gestureData[g]?.samples.length > idx) {
1660
+ S.gestureData[g].samples.splice(idx, 1);
1661
+ S.modelTrained = false; Model.clear(); // model invalidated
1662
+ updateCalUI(); toast(`Sample ${idx+1} removed`, "info");
1663
+ }
1664
+ }
1665
+
1666
+ function resetCurrentGesture() {
1667
+ const g = GESTURES[S.curGestIdx];
1668
+ S.gestureData[g] = { samples: [] };
1669
+ S.modelTrained = false;
1670
+ updateCalUI(); toast(`${INFO[g].name} samples cleared`, "info");
1671
+ }
1672
+
1673
+ async function nextGesture() {
1674
+ if (S.curGestIdx < GESTURES.length - 1) {
1675
+ S.curGestIdx++;
1676
+ updateCalUI();
1677
+ } else {
1678
+ // All gestures recorded → train model then validate
1679
+ try {
1680
+ const success = await trainModel();
1681
+ if (success) {
1682
+ await sleep(800);
1683
+ startValidation();
1684
+ }
1685
+ } catch (e) {
1686
+ console.error("Training failed:", e);
1687
+ toast("Training error: " + e.message, "error");
1688
+ }
1689
+ }
1690
+ }
1691
+
1692
+ function skipCalibration() {
1693
+ S.quickMode = true;
1694
+ S.modelTrained = false;
1695
+ toast("Test mode: Press R=Rock, P=Paper, S=Scissors during play", "info");
1696
+ startGame();
1697
+ }
1698
+
1699
+ function quickPlay() {
1700
+ S.quickMode = true;
1701
+ S.modelTrained = false;
1702
+ toast("Demo mode: Press R=Rock, P=Paper, S=Scissors", "info");
1703
+ startGame();
1704
+ }
1705
+
1706
+ // ============ VALIDATION ============
1707
+ function startValidation() {
1708
+ S.valIdx = 0; S.valPassed = 0;
1709
+ showScreen("val");
1710
+ runValRound();
1711
+ }
1712
+
1713
+ function runValRound() {
1714
+ if (S.valIdx >= GESTURES.length) {
1715
+ if (S.valPassed >= 2) {
1716
+ document.getElementById("btnStartGame").disabled = false;
1717
+ document.getElementById("btnStartAnyway").style.display = "none";
1718
+ document.getElementById("valNote").style.display = "none";
1719
+ toast("Validation passed! Model is ready!", "success");
1720
+ } else {
1721
+ document.getElementById("btnStartGame").disabled = true;
1722
+ document.getElementById("btnStartAnyway").style.display = "inline-block";
1723
+ document.getElementById("valNote").style.display = "block";
1724
+ toast(`Validation: ${S.valPassed}/3 passed.`, "info");
1725
+ }
1726
+ return;
1727
+ }
1728
+
1729
+ const g = GESTURES[S.valIdx];
1730
+ document.getElementById("valIcon").textContent = INFO[g].icon;
1731
+ document.getElementById("valText").textContent = `Get ready to perform: ${INFO[g].name}`;
1732
+ document.getElementById("valResult").textContent = "Get ready...";
1733
+ document.getElementById("valResult").style.color = "var(--warning)";
1734
+ document.getElementById("valDetected").textContent = "Waiting...";
1735
+ document.getElementById("valConf").textContent = "0%";
1736
+ document.getElementById("valBar").style.width = "0%";
1737
+
1738
+ let cd = 2;
1739
+ const cdInt = setInterval(() => {
1740
+ if (cd > 0) { document.getElementById("valResult").textContent = `Starting in ${cd}...`; Sfx.playCountdown(cd); cd--; }
1741
+ else {
1742
+ clearInterval(cdInt);
1743
+ document.getElementById("valText").textContent = `Perform: ${INFO[g].name} NOW!`;
1744
+ document.getElementById("valResult").textContent = "Hold the gesture...";
1745
+ document.getElementById("valResult").style.color = "var(--primary)";
1746
+ Sfx.playShoot();
1747
+ detectForValidation(g);
1748
+ }
1749
+ }, 1000);
1750
+ }
1751
+
1752
+ function detectForValidation(expected) {
1753
+ const start = Date.now();
1754
+ const readyMs = 500, detectMs = 2500;
1755
+ let lastR = { gesture: null, confidence: 0, probs: [0.33,0.33,0.33] };
1756
+
1757
+ // Helper: build merged buffer from recBuf for prediction
1758
+ const buildBuf = () => {
1759
+ const accE = S.recBuf.imu.filter(d => d.type === "acc");
1760
+ const gyroE = S.recBuf.imu.filter(d => d.type === "gyro");
1761
+ return {
1762
+ imu: accE.map((a, i) => ({ acc: [...a.values], gyro: gyroE[i] ? [...gyroE[i].values] : [0,0,0] })),
1763
+ snc: S.recBuf.snc.map(d => [...d.values])
1764
+ };
1765
+ };
1766
+
1767
+ const showResult = (r) => {
1768
+ S.isRecording = false;
1769
+ const rEl = document.getElementById("valResult");
1770
+ document.getElementById("valDetected").textContent = r.gesture ? INFO[r.gesture].name : "Unknown";
1771
+ document.getElementById("valConf").textContent = `${Math.round(r.confidence*100)}%`;
1772
+ document.getElementById("valBar").style.width = "100%";
1773
+
1774
+ if (r.gesture === expected) {
1775
+ rEl.textContent = `Correct! (${Math.round(r.confidence*100)}% confidence)`;
1776
+ rEl.style.color = "var(--success)";
1777
+ S.valPassed++; Sfx.playWin();
1778
+ } else {
1779
+ rEl.textContent = `Expected ${INFO[expected].name}, got ${r.gesture ? INFO[r.gesture].name : "Unknown"}`;
1780
+ rEl.style.color = "var(--error)"; Sfx.playLose();
1781
+ }
1782
+ setTimeout(() => { S.valIdx++; runValRound(); }, 2000);
1783
+ };
1784
+
1785
+ const tick = () => {
1786
+ const el = Date.now() - start;
1787
+ const detEl = Math.max(0, el - readyMs);
1788
+ document.getElementById("valBar").style.width = `${Math.min(100, (detEl/detectMs)*100)}%`;
1789
+
1790
+ // Start recording once ready period is over
1791
+ if (el >= readyMs && !S.isRecording) {
1792
+ S.isRecording = true;
1793
+ S.recBuf = { imu: [], snc: [] };
1794
+ }
1795
+
1796
+ if (el < readyMs + detectMs) {
1797
+ if (S.isRecording) {
1798
+ const buf = buildBuf();
1799
+ if (buf.imu.length > 10) {
1800
+ lastR = predictGesture(buf);
1801
+ document.getElementById("valDetected").textContent = lastR.gesture ? INFO[lastR.gesture].name : "Analyzing...";
1802
+ document.getElementById("valConf").textContent = `${Math.round(lastR.confidence*100)}%`;
1803
+ if (lastR.confidence > 0.88 && lastR.gesture) { showResult(lastR); return; }
1804
+ }
1805
+ }
1806
+ requestAnimationFrame(tick);
1807
+ } else {
1808
+ const buf = buildBuf();
1809
+ const r = buf.imu.length > 5 ? predictGesture(buf) : lastR;
1810
+ showResult(r);
1811
+ }
1812
+ };
1813
+ tick();
1814
+ }
1815
+
1816
+ // ============ GAME ============
1817
+ let gameActive = false;
1818
+
1819
+ function startGame() {
1820
+ S.pScore = 0; S.aScore = 0; S.roundHistory = [];
1821
+ S.usage = { rock:0, paper:0, scissors:0 };
1822
+ S.respTimes = []; S.streak = 0; S.bestStreak = 0;
1823
+ S.totalDetect = 0; S.successDetect = 0;
1824
+ gameActive = true;
1825
+ showScreen("game"); updateScores(); resetArena();
1826
+ setTimeout(() => { if (gameActive) startRound(); }, 1000);
1827
+ }
1828
+
1829
+ function stopGame() {
1830
+ gameActive = false;
1831
+ S.isPlaying = false;
1832
+ }
1833
+
1834
+ function updateScores() {
1835
+ document.getElementById("scorePlayer").textContent = S.pScore;
1836
+ document.getElementById("scoreAi").textContent = S.aScore;
1837
+ }
1838
+
1839
+ function resetArena() {
1840
+ document.getElementById("pGesture").textContent = "\u{2753}"; document.getElementById("pGesture").className = "player-gesture";
1841
+ document.getElementById("pName").textContent = "Ready";
1842
+ document.getElementById("aGesture").textContent = "\u{2753}"; document.getElementById("aGesture").className = "player-gesture";
1843
+ document.getElementById("aName").textContent = "Ready";
1844
+ document.getElementById("vsText").style.display = "block";
1845
+ document.getElementById("cdNum").textContent = "";
1846
+ document.getElementById("gameStatus").textContent = "Get ready for the next round!";
1847
+ document.getElementById("btnNextRound").style.display = "none";
1848
+ document.getElementById("gameDetectWin").style.display = "none";
1849
+ }
1850
+
1851
+ async function startRound() {
1852
+ if (!gameActive) return;
1853
+ S.isPlaying = true; resetArena();
1854
+ document.getElementById("vsText").style.display = "none";
1855
+ const cdEl = document.getElementById("cdNum");
1856
+ for (let i = 3; i >= 1; i--) {
1857
+ if (!gameActive) return;
1858
+ cdEl.textContent = i; cdEl.style.animation = "none"; cdEl.offsetHeight;
1859
+ cdEl.style.animation = "cd-pulse 900ms ease-in-out"; Sfx.playCountdown(i); await sleep(1000);
1860
+ }
1861
+ if (!gameActive) return;
1862
+ cdEl.textContent = "SHOOT!"; Sfx.playShoot(); await sleep(500);
1863
+ if (!gameActive) return;
1864
+ cdEl.textContent = ""; document.getElementById("vsText").style.display = "block";
1865
+ detectPlayerGesture();
1866
+ }
1867
+
1868
+ function detectPlayerGesture() {
1869
+ document.getElementById("gameDetectWin").style.display = "block";
1870
+ document.getElementById("gameStatus").textContent = "Make your gesture now!";
1871
+ const start = Date.now(), dur = S.detectionMs;
1872
+ let lastR = { gesture: null, confidence: 0, probs: [0.33,0.33,0.33] };
1873
+
1874
+ // Use event-driven recording (same as calibration & debug) for full data density
1875
+ S.isRecording = true;
1876
+ S.recBuf = { imu: [], snc: [] };
1877
+
1878
+ // Helper: build merged buffer from recBuf for prediction
1879
+ const buildBuf = () => {
1880
+ const accE = S.recBuf.imu.filter(d => d.type === "acc");
1881
+ const gyroE = S.recBuf.imu.filter(d => d.type === "gyro");
1882
+ return {
1883
+ imu: accE.map((a, i) => ({ acc: [...a.values], gyro: gyroE[i] ? [...gyroE[i].values] : [0,0,0] })),
1884
+ snc: S.recBuf.snc.map(d => [...d.values])
1885
+ };
1886
+ };
1887
+
1888
+ const tick = () => {
1889
+ if (!gameActive) { S.isRecording = false; return; }
1890
+ const el = Date.now() - start;
1891
+ document.getElementById("gameDetectBar").style.width = `${Math.min(100,(el/dur)*100)}%`;
1892
+ document.getElementById("gameDetectTime").textContent = `${((dur-el)/1000).toFixed(1)}s`;
1893
+
1894
+ const buf = buildBuf();
1895
+ if (S.modelTrained && buf.imu.length > 5) {
1896
+ lastR = predictGesture(buf);
1897
+ if (lastR.confidence > 0.88 && lastR.gesture) { S.isRecording = false; finishRound(lastR, el); return; }
1898
+ }
1899
+
1900
+ if (el < dur) requestAnimationFrame(tick);
1901
+ else { S.isRecording = false; finishRound(lastR, dur); }
1902
+ };
1903
+ tick();
1904
+ }
1905
+
1906
+ // Store last round's data for potential correction
1907
+ let lastRoundRecBuf = null;
1908
+ let lastRoundDetectedGesture = null;
1909
+ let lastRoundAiGesture = null;
1910
+ let lastRoundResult = null;
1911
+
1912
+ function finishRound(pResult, respTime) {
1913
+ if (!gameActive) return;
1914
+ S.isPlaying = false; S.totalDetect++;
1915
+ document.getElementById("gameDetectWin").style.display = "none";
1916
+ let pg = pResult.gesture;
1917
+
1918
+ // Save recBuf for potential correction (deep copy)
1919
+ lastRoundRecBuf = S.recBuf ? JSON.parse(JSON.stringify(S.recBuf)) : null;
1920
+
1921
+ if (S.quickMode && quickPlayGesture) { pg = quickPlayGesture; quickPlayGesture = null; S.successDetect++; }
1922
+ else if (!pg) {
1923
+ pg = GESTURES[Math.floor(Math.random() * 3)];
1924
+ if (!S.quickMode) toast("No gesture detected — random selection", "info");
1925
+ } else { S.successDetect++; }
1926
+
1927
+ lastRoundDetectedGesture = pg;
1928
+
1929
+ S.respTimes.push(respTime); S.usage[pg]++;
1930
+ const ag = aiChoice();
1931
+ lastRoundAiGesture = ag;
1932
+
1933
+ document.getElementById("pGesture").textContent = ICONS[pg];
1934
+ document.getElementById("pName").textContent = INFO[pg].name;
1935
+ document.getElementById("aGesture").textContent = ICONS[ag];
1936
+ document.getElementById("aName").textContent = INFO[ag].name;
1937
+
1938
+ const result = winner(pg, ag);
1939
+ lastRoundResult = result;
1940
+ S.roundHistory.push({ player: pg, ai: ag, result });
1941
+ const pEl = document.getElementById("pGesture"), aEl = document.getElementById("aGesture"), status = document.getElementById("gameStatus");
1942
+
1943
+ if (result === "win") { S.pScore++; S.streak++; S.bestStreak = Math.max(S.bestStreak, S.streak); pEl.classList.add("win"); aEl.classList.add("lose"); status.innerHTML = '<span class="result-text win">YOU WIN!</span>'; Sfx.playWin(); }
1944
+ else if (result === "lose") { S.aScore++; S.streak = 0; pEl.classList.add("lose"); aEl.classList.add("win"); status.innerHTML = '<span class="result-text lose">AI WINS!</span>'; Sfx.playLose(); }
1945
+ else { pEl.classList.add("draw"); aEl.classList.add("draw"); status.innerHTML = '<span class="result-text draw">DRAW!</span>'; Sfx.playDraw(); }
1946
+
1947
+ updateScores();
1948
+ document.getElementById("gameCorrectionPanel").style.display = "none";
1949
+ const isMatchOver = S.roundsToWin > 0 && (S.pScore >= S.roundsToWin || S.aScore >= S.roundsToWin);
1950
+ // Always show Wrong? button so user can correct even the final round
1951
+ document.getElementById("btnWrongGesture").style.display = (lastRoundRecBuf && !S.quickMode) ? "inline-block" : "none";
1952
+ if (isMatchOver) {
1953
+ // Show "End Match" instead of "Next Round", give user time to correct
1954
+ document.getElementById("btnNextRound").textContent = "End Match";
1955
+ document.getElementById("btnNextRound").style.display = "inline-block";
1956
+ S._pendingMatchResult = S.pScore >= S.roundsToWin ? "victory" : "defeat";
1957
+ } else {
1958
+ document.getElementById("btnNextRound").textContent = "Next Round";
1959
+ document.getElementById("btnNextRound").style.display = "inline-block";
1960
+ S._pendingMatchResult = null;
1961
+ }
1962
+ }
1963
+
1964
+ function showGameCorrection() {
1965
+ document.getElementById("gameCorrectionPanel").style.display = "block";
1966
+ document.getElementById("btnWrongGesture").style.display = "none";
1967
+ // Pre-highlight the detected gesture
1968
+ document.querySelectorAll(".game-correct-btn").forEach(btn => {
1969
+ btn.classList.remove("correct", "incorrect", "selected-correct");
1970
+ if (btn.dataset.gesture === lastRoundDetectedGesture) btn.classList.add("incorrect");
1971
+ });
1972
+ }
1973
+
1974
+ function correctGameGesture(actualGesture) {
1975
+ if (!lastRoundRecBuf) return;
1976
+
1977
+ // Save the corrected sample as training data
1978
+ const stab = calcStability(lastRoundRecBuf);
1979
+ S.gestureData[actualGesture].samples.push({
1980
+ timestamp: new Date().toISOString(),
1981
+ stability: stab,
1982
+ source: 'game-correction',
1983
+ imu_data: {
1984
+ accelerometer: lastRoundRecBuf.imu.filter(d => d.type === "acc").map(d => d.values),
1985
+ gyroscope: lastRoundRecBuf.imu.filter(d => d.type === "gyro").map(d => d.values)
1986
+ },
1987
+ snc_data: { channels: lastRoundRecBuf.snc.map(d => d.values) }
1988
+ });
1989
+ S.debugNewSamples = (S.debugNewSamples || 0) + 1;
1990
+
1991
+ // Visual feedback
1992
+ document.querySelectorAll(".game-correct-btn").forEach(btn => {
1993
+ btn.classList.remove("correct", "incorrect", "selected-correct");
1994
+ if (btn.dataset.gesture === actualGesture) btn.classList.add("selected-correct");
1995
+ });
1996
+
1997
+ // Recalculate the round with the correct gesture
1998
+ const oldPg = lastRoundDetectedGesture;
1999
+ const newResult = winner(actualGesture, lastRoundAiGesture);
2000
+
2001
+ // Undo old score
2002
+ S.usage[oldPg]--;
2003
+ if (lastRoundResult === "win") { S.pScore--; }
2004
+ else if (lastRoundResult === "lose") { S.aScore--; }
2005
+
2006
+ // Apply new score
2007
+ S.usage[actualGesture]++;
2008
+ const lastEntry = S.roundHistory[S.roundHistory.length - 1];
2009
+ lastEntry.player = actualGesture;
2010
+ lastEntry.result = newResult;
2011
+ lastEntry.corrected = true;
2012
+
2013
+ if (newResult === "win") S.pScore++;
2014
+ else if (newResult === "lose") S.aScore++;
2015
+
2016
+ // Update UI
2017
+ document.getElementById("pGesture").textContent = ICONS[actualGesture];
2018
+ document.getElementById("pName").textContent = INFO[actualGesture].name;
2019
+ const pEl = document.getElementById("pGesture"), aEl = document.getElementById("aGesture"), status = document.getElementById("gameStatus");
2020
+ pEl.className = "player-gesture"; aEl.className = "player-gesture";
2021
+ if (newResult === "win") { pEl.classList.add("win"); aEl.classList.add("lose"); status.innerHTML = '<span class="result-text win">YOU WIN!</span>'; }
2022
+ else if (newResult === "lose") { pEl.classList.add("lose"); aEl.classList.add("win"); status.innerHTML = '<span class="result-text lose">AI WINS!</span>'; }
2023
+ else { pEl.classList.add("draw"); aEl.classList.add("draw"); status.innerHTML = '<span class="result-text draw">DRAW!</span>'; }
2024
+ updateScores();
2025
+
2026
+ lastRoundDetectedGesture = actualGesture;
2027
+ lastRoundResult = newResult;
2028
+
2029
+ toast(`Corrected → ${INFO[actualGesture].name} (sample saved)`, "info");
2030
+
2031
+ // Hide correction panel after brief moment
2032
+ setTimeout(() => {
2033
+ document.getElementById("gameCorrectionPanel").style.display = "none";
2034
+ // Recalculate pending match result after correction
2035
+ if (S.roundsToWin > 0) {
2036
+ const isMatchOver = S.pScore >= S.roundsToWin || S.aScore >= S.roundsToWin;
2037
+ if (isMatchOver) {
2038
+ S._pendingMatchResult = S.pScore >= S.roundsToWin ? "victory" : "defeat";
2039
+ document.getElementById("btnNextRound").textContent = "End Match";
2040
+ } else {
2041
+ S._pendingMatchResult = null;
2042
+ document.getElementById("btnNextRound").textContent = "Next Round";
2043
+ }
2044
+ }
2045
+ }, 800);
2046
+
2047
+ lastRoundRecBuf = null; // prevent double-correction
2048
+ }
2049
+
2050
+ function aiChoice() {
2051
+ const total = S.usage.rock + S.usage.paper + S.usage.scissors;
2052
+ if (total > 3 && Math.random() < 0.4) {
2053
+ const rp = S.usage.rock/total, pp = S.usage.paper/total, sp = S.usage.scissors/total;
2054
+ const mx = Math.max(rp, pp, sp);
2055
+ if (mx === rp) return "paper"; if (mx === pp) return "scissors"; return "rock";
2056
+ }
2057
+ return GESTURES[Math.floor(Math.random() * 3)];
2058
+ }
2059
+
2060
+ function winner(p, a) {
2061
+ if (p === a) return "draw";
2062
+ return { rock:"scissors", paper:"rock", scissors:"paper" }[p] === a ? "win" : "lose";
2063
+ }
2064
+
2065
+ // ============ RESULTS ============
2066
+ function showResults(outcome) {
2067
+ gameActive = false;
2068
+ // Save any correction samples collected during the game
2069
+ if (S.debugNewSamples > 0) {
2070
+ try { autoSaveProfile(); } catch (e) { console.warn("Save after game failed:", e); }
2071
+ }
2072
+ showScreen("results");
2073
+ const r = document.getElementById("finalResult");
2074
+ if (outcome === "victory") { r.textContent = "VICTORY!"; r.className = "final-result victory"; Sfx.playVictoryFanfare(); createConfetti(); }
2075
+ else { r.textContent = "DEFEAT"; r.className = "final-result defeat"; Sfx.playDefeatSound(); }
2076
+ document.getElementById("finalPlayer").textContent = S.pScore;
2077
+ document.getElementById("finalAi").textContent = S.aScore;
2078
+ document.getElementById("statRounds").textContent = S.roundHistory.length;
2079
+ document.getElementById("statAccuracy").textContent = S.totalDetect > 0 ? `${Math.round(S.successDetect/S.totalDetect*100)}%` : "0%";
2080
+ document.getElementById("statAvgTime").textContent = S.respTimes.length > 0 ? `${Math.round(S.respTimes.reduce((a,b)=>a+b,0)/S.respTimes.length)}ms` : "0ms";
2081
+ document.getElementById("statStreak").textContent = S.bestStreak;
2082
+ document.getElementById("usageRock").textContent = S.usage.rock;
2083
+ document.getElementById("usagePaper").textContent = S.usage.paper;
2084
+ document.getElementById("usageScissors").textContent = S.usage.scissors;
2085
+ }
2086
+
2087
+ function createConfetti() {
2088
+ const colors = ["#77eae9","#22c55e","#ff3d8a","#ffd700","#a855f7"];
2089
+ for (let i = 0; i < 50; i++) {
2090
+ const el = document.createElement("div");
2091
+ el.style.cssText = `position:fixed; width:10px; height:10px; background:${colors[Math.floor(Math.random()*colors.length)]}; left:${Math.random()*100}vw; top:-10px; border-radius:${Math.random()>0.5?"50%":"0"}; pointer-events:none; z-index:9999; animation:confetti-fall ${2+Math.random()*2}s linear forwards;`;
2092
+ document.body.appendChild(el); setTimeout(() => el.remove(), 4000);
2093
+ }
2094
+ }
2095
+
2096
+ // ============ SENSOR VIZ ============
2097
+ let accCanvas, accCtx, gyroCanvas, gyroCtx, vizRunning = false;
2098
+ function startSensorViz() {
2099
+ accCanvas = document.getElementById("canvasAcc"); accCtx = accCanvas?.getContext("2d");
2100
+ gyroCanvas = document.getElementById("canvasGyro"); gyroCtx = gyroCanvas?.getContext("2d");
2101
+ resizeCanvases();
2102
+ if (!vizRunning) { vizRunning = true; requestAnimationFrame(renderLoop); }
2103
+ }
2104
+
2105
+ function resizeCanvases() { [accCanvas, gyroCanvas, dbgAccCanvas, dbgGyroCanvas].forEach(c => { if (c) { c.width = c.offsetWidth * devicePixelRatio; c.height = c.offsetHeight * devicePixelRatio; } }); }
2106
+
2107
+ let liveRecogCounter = 0;
2108
+ const INFER_BUF_MAX = 45; // ~1.5s at sensor rate
2109
+ let inferLastImuSeq = 0, inferLastSncSeq = 0;
2110
+ function renderLoop() {
2111
+ // Update rolling inference buffer ONLY when new sensor data arrives
2112
+ // (matches calibration recording rate — prevents duplicate entries that skew std features)
2113
+ if (S.imuSeq !== inferLastImuSeq) {
2114
+ S.inferBuf.imu.push({ acc: [...S.curAcc], gyro: [...S.curGyro] });
2115
+ if (S.inferBuf.imu.length > INFER_BUF_MAX) S.inferBuf.imu.shift();
2116
+ inferLastImuSeq = S.imuSeq;
2117
+ }
2118
+ if (S.sncSeq !== inferLastSncSeq) {
2119
+ S.inferBuf.snc.push([...S.curSnc]);
2120
+ if (S.inferBuf.snc.length > INFER_BUF_MAX) S.inferBuf.snc.shift();
2121
+ inferLastSncSeq = S.sncSeq;
2122
+ }
2123
+ renderIMU(accCtx, S.accHist, accCanvas);
2124
+ renderIMU(gyroCtx, S.gyroHist, gyroCanvas);
2125
+ updateLiveRecognition();
2126
+ updateDebug();
2127
+ // Debug mode rendering
2128
+ if (document.getElementById("screen-debug")?.classList.contains("active")) {
2129
+ renderIMU(dbgAccCtx, S.accHist, dbgAccCanvas);
2130
+ renderIMU(dbgGyroCtx, S.gyroHist, dbgGyroCanvas);
2131
+ drawSncWave("canvasSncDebug");
2132
+ updateDebugSensorValues();
2133
+ if (S.debugPhase === 'live') updateDebugLivePrediction();
2134
+ }
2135
+ requestAnimationFrame(renderLoop);
2136
+ }
2137
+
2138
+ function renderIMU(ctx, hist, canvas) {
2139
+ if (!ctx || !canvas) return;
2140
+ const w = canvas.width, h = canvas.height;
2141
+ ctx.fillStyle = "rgba(0,0,0,0.15)"; ctx.fillRect(0, 0, w, h);
2142
+ if (hist.x.length < 2) return;
2143
+ const cols = { x:"#ff6b6b", y:"#4ecdc4", z:"#ffe66d" };
2144
+ const step = w / (hist.x.length - 1);
2145
+ ["x","y","z"].forEach(axis => {
2146
+ ctx.strokeStyle = cols[axis]; ctx.lineWidth = 2 * devicePixelRatio; ctx.beginPath();
2147
+ hist[axis].forEach((val, i) => { const x = i*step; const y = h - ((val+20)/40*h); i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y); });
2148
+ ctx.stroke();
2149
+ });
2150
+ }
2151
+
2152
+ function drawSncWave(canvasId = "canvasSnc") {
2153
+ const canvas = document.getElementById(canvasId); if (!canvas) return;
2154
+ const ctx = canvas.getContext("2d"), r = canvas.getBoundingClientRect();
2155
+ canvas.width = r.width * devicePixelRatio; canvas.height = r.height * devicePixelRatio;
2156
+ ctx.scale(devicePixelRatio, devicePixelRatio);
2157
+ const w = r.width, h = r.height;
2158
+ ctx.clearRect(0, 0, w, h);
2159
+ ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1;
2160
+ ctx.beginPath(); ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke();
2161
+ const chColors = ["#00d4ff","#22c55e","#ff6b6b"];
2162
+ S.sncBufs.forEach((buf, ci) => {
2163
+ if (buf.length < 2) return;
2164
+ const data = buf.slice(-100);
2165
+ ctx.strokeStyle = chColors[ci]; ctx.lineWidth = 2; ctx.beginPath();
2166
+ const step = w / (data.length - 1);
2167
+ data.forEach((val, i) => { const y = ((1-val)/2)*(h-10)+5; i===0 ? ctx.moveTo(i*step, y) : ctx.lineTo(i*step, y); });
2168
+ ctx.stroke();
2169
+ });
2170
+ }
2171
+
2172
+ function updateLiveRecognition() {
2173
+ liveRecogCounter++;
2174
+ if (liveRecogCounter < 5) return;
2175
+ liveRecogCounter = 0;
2176
+ if (!document.getElementById("screen-cal")?.classList.contains("active")) return;
2177
+ if (!S.modelTrained) {
2178
+ setEl("liveIcon", "-"); setEl("liveName", "Train model first"); return;
2179
+ }
2180
+ const r = predictGesture(S.inferBuf);
2181
+ if (r.gesture && r.confidence > 0.4) {
2182
+ setEl("liveIcon", ICONS[r.gesture]||"?");
2183
+ const el = document.getElementById("liveName");
2184
+ el.textContent = `${INFO[r.gesture].name} (${(r.confidence*100).toFixed(0)}%)`;
2185
+ el.style.color = r.confidence > 0.6 ? "var(--success)" : "var(--primary)";
2186
+ } else { setEl("liveIcon", "?"); const el = document.getElementById("liveName"); el.textContent = "Unknown"; el.style.color = "var(--text-secondary)"; }
2187
+ if (r.probs) {
2188
+ setEl("liveRock", `${(r.probs[0]*100).toFixed(0)}%`);
2189
+ setEl("livePaper", `${(r.probs[1]*100).toFixed(0)}%`);
2190
+ setEl("liveScissors", `${(r.probs[2]*100).toFixed(0)}%`);
2191
+ }
2192
+ }
2193
+
2194
+ // ============ DEBUG ============
2195
+ let debugOn = false;
2196
+ function toggleDebug() { debugOn = !debugOn; document.getElementById("debugPanel").classList.toggle("active", debugOn); }
2197
+ function updateDebug() {
2198
+ if (!debugOn) return;
2199
+ setEl("dbgAcc", `[${S.curAcc.map(v=>v.toFixed(2)).join(", ")}]`);
2200
+ setEl("dbgGyro", `[${S.curGyro.map(v=>v.toFixed(2)).join(", ")}]`);
2201
+ setEl("dbgSnc", `[${S.curSnc.map(v=>v.toFixed(2)).join(", ")}]`);
2202
+
2203
+ if (!S.modelTrained) return;
2204
+ const r = predictGesture(S.inferBuf);
2205
+ if (r.probs) {
2206
+ setEl("dbgRock", `${(r.probs[0]*100).toFixed(1)}%`);
2207
+ setEl("dbgPaper", `${(r.probs[1]*100).toFixed(1)}%`);
2208
+ setEl("dbgScissors", `${(r.probs[2]*100).toFixed(1)}%`);
2209
+ }
2210
+ setEl("dbgDetected", r.gesture ? INFO[r.gesture].name : "Unknown");
2211
+ setEl("dbgConf", `${(r.confidence*100).toFixed(1)}%`);
2212
+ }
2213
+
2214
+ // ============ PROFILES (named, persistent calibration data) ============
2215
+ // Profiles (gesture data) and model cache are stored SEPARATELY:
2216
+ // Profiles: PROFILES_KEY → [{id, name, hand, gestureData, ...}] (always saved)
2217
+ // Models: MODEL_KEY_PREFIX + id → {type, scaler, mlp, rf, meta} (best-effort cache)
2218
+ const PROFILES_KEY = "mudra-duel-v4-profiles";
2219
+ const MODEL_KEY_PREFIX = "mudra-duel-v4-model-";
2220
+
2221
+ // Migrate v3 data on first load
2222
+ (function migrateFromV3() {
2223
+ if (localStorage.getItem(PROFILES_KEY)) return; // already have v4 data
2224
+ const v3Profiles = localStorage.getItem("mudra-duel-v3-profiles");
2225
+ if (!v3Profiles) return; // no v3 data to migrate
2226
+ try {
2227
+ const profiles = JSON.parse(v3Profiles);
2228
+ if (!profiles.length) return;
2229
+ // Move (not copy) to avoid exceeding localStorage quota:
2230
+ // remove v3 first to free space, then write v4
2231
+ localStorage.removeItem("mudra-duel-v3-profiles");
2232
+ localStorage.setItem(PROFILES_KEY, v3Profiles);
2233
+ // Move model caches too (collect keys first, then move)
2234
+ const v3ModelKeys = [];
2235
+ for (let i = 0; i < localStorage.length; i++) {
2236
+ const key = localStorage.key(i);
2237
+ if (key?.startsWith("mudra-duel-v3-model-")) v3ModelKeys.push(key);
2238
+ }
2239
+ v3ModelKeys.forEach(key => {
2240
+ const id = key.slice("mudra-duel-v3-model-".length);
2241
+ const data = localStorage.getItem(key);
2242
+ localStorage.removeItem(key);
2243
+ try { localStorage.setItem(MODEL_KEY_PREFIX + id, data); } catch {}
2244
+ });
2245
+ console.log(`[Migration] Moved ${profiles.length} profiles from v3 → v4`);
2246
+ // Trigger folder sync so the data is written to the project folder
2247
+ syncToFolder();
2248
+ } catch (e) { console.warn("[Migration] Failed to migrate v3 data:", e); }
2249
+ })();
2250
+
2251
+ function getProfiles() { try { return JSON.parse(localStorage.getItem(PROFILES_KEY)||"[]"); } catch { return []; } }
2252
+
2253
+ function storeProfiles(p) {
2254
+ // NEVER include modelData in the profiles blob — it's stored separately
2255
+ const clean = p.map(({ modelData, ...rest }) => rest);
2256
+ const json = JSON.stringify(clean);
2257
+ console.log(`storeProfiles: ${clean.length} profiles, ${(json.length/1024).toFixed(0)}KB`);
2258
+ try {
2259
+ localStorage.setItem(PROFILES_KEY, json);
2260
+ syncToFolder();
2261
+ return true;
2262
+ } catch (e) {
2263
+ console.warn("Profile save failed, clearing model cache to free space...", e.message);
2264
+ // Model cache is expendable — clear ALL of it to make room for profile data
2265
+ clearAllModelCache();
2266
+ try {
2267
+ localStorage.setItem(PROFILES_KEY, json);
2268
+ console.log("Profile save succeeded after clearing model cache");
2269
+ return true;
2270
+ } catch (e2) {
2271
+ console.error("Profile save STILL failed after cache cleanup:", e2.message);
2272
+ throw e2;
2273
+ }
2274
+ }
2275
+ }
2276
+
2277
+ function clearAllModelCache() {
2278
+ const keysToRemove = [];
2279
+ for (let i = 0; i < localStorage.length; i++) {
2280
+ const key = localStorage.key(i);
2281
+ if (key?.startsWith(MODEL_KEY_PREFIX)) keysToRemove.push(key);
2282
+ }
2283
+ keysToRemove.forEach(k => { localStorage.removeItem(k); console.log(`Cleared model cache: ${k}`); });
2284
+ console.log(`Cleared ${keysToRemove.length} model cache entries`);
2285
+ }
2286
+
2287
+ // Save/load model data in its own key (best-effort, won't break profile save if it fails)
2288
+ function storeModelData(profileId, modelData) {
2289
+ if (!modelData) return false;
2290
+ try {
2291
+ const json = JSON.stringify(modelData);
2292
+ console.log(`storeModelData: ${profileId}, ${(json.length/1024).toFixed(0)}KB`);
2293
+ localStorage.setItem(MODEL_KEY_PREFIX + profileId, json);
2294
+ syncToFolder();
2295
+ return true;
2296
+ } catch (e) {
2297
+ console.warn(`Model cache failed for ${profileId} (${e.message}) — will retrain on load`);
2298
+ // Try to clean up old model caches to make room
2299
+ try {
2300
+ const profiles = getProfiles();
2301
+ const activeIds = new Set(profiles.map(p => p.id));
2302
+ for (let i = 0; i < localStorage.length; i++) {
2303
+ const key = localStorage.key(i);
2304
+ if (key?.startsWith(MODEL_KEY_PREFIX)) {
2305
+ const mid = key.slice(MODEL_KEY_PREFIX.length);
2306
+ if (!activeIds.has(mid)) { localStorage.removeItem(key); console.log(`Cleaned orphan model: ${key}`); }
2307
+ }
2308
+ }
2309
+ // Retry after cleanup
2310
+ localStorage.setItem(MODEL_KEY_PREFIX + profileId, json);
2311
+ return true;
2312
+ } catch { return false; }
2313
+ }
2314
+ }
2315
+
2316
+ function loadModelData(profileId) {
2317
+ try {
2318
+ const raw = localStorage.getItem(MODEL_KEY_PREFIX + profileId);
2319
+ return raw ? JSON.parse(raw) : null;
2320
+ } catch { return null; }
2321
+ }
2322
+
2323
+ function removeModelData(profileId) {
2324
+ try { localStorage.removeItem(MODEL_KEY_PREFIX + profileId); } catch {}
2325
+ }
2326
+
2327
+ // Save/overwrite profile by name (called automatically after training)
2328
+ function saveProfile(name, gestureData, modelData) {
2329
+ if (!name) { console.warn("saveProfile: no name"); return false; }
2330
+ const profiles = getProfiles();
2331
+ const ei = profiles.findIndex(p => p.name.toLowerCase() === name.toLowerCase());
2332
+ let oldId = null;
2333
+ if (ei >= 0) { oldId = profiles[ei].id; profiles.splice(ei, 1); }
2334
+ const id = Date.now().toString();
2335
+ profiles.unshift({
2336
+ id, name,
2337
+ hand: S.hand || "right",
2338
+ createdAt: new Date().toISOString(),
2339
+ updatedAt: new Date().toISOString(),
2340
+ gestureData: JSON.parse(JSON.stringify(gestureData)),
2341
+ samplesPerGesture: GESTURES.map(g => gestureData[g]?.samples?.length || 0)
2342
+ });
2343
+ // 1. Save profile data (gesture data) — this is the critical save
2344
+ storeProfiles(profiles);
2345
+ // 2. Cache model data separately (best-effort, won't block profile save)
2346
+ if (oldId) removeModelData(oldId);
2347
+ const modelSaved = storeModelData(id, modelData);
2348
+ console.log(`Profile "${name}" saved (id=${id}, model=${modelSaved ? 'cached' : 'not cached'})`);
2349
+ // Store the current profile's ID for later use
2350
+ S.currentProfileId = id;
2351
+ return modelSaved ? true : 'no_model';
2352
+ }
2353
+
2354
+ // Auto-save the current session's profile
2355
+ function autoSaveProfile() {
2356
+ if (!S.profileName || !S.modelTrained) {
2357
+ console.warn("autoSaveProfile skipped:", { name: S.profileName, trained: S.modelTrained });
2358
+ return false;
2359
+ }
2360
+ const result = saveProfile(S.profileName, S.gestureData, Model.serialize());
2361
+ // Verify it was actually saved
2362
+ const saved = getProfiles();
2363
+ const found = saved.find(p => p.name.toLowerCase() === S.profileName.toLowerCase());
2364
+ if (found) {
2365
+ const note = result === 'no_model' ? ' (will retrain on load)' : '';
2366
+ toast(`Profile "${S.profileName}" saved${note}`, "success");
2367
+ console.log(`autoSaveProfile OK: "${S.profileName}" (${found.samplesPerGesture} samples)`);
2368
+ } else {
2369
+ toast(`⚠ Failed to save "${S.profileName}"`, "error");
2370
+ console.error("autoSaveProfile VERIFY FAILED — profile not in storage!");
2371
+ }
2372
+ updateProfilesUI();
2373
+ return !!found;
2374
+ }
2375
+
2376
+ // Load profile → go straight to game
2377
+ async function loadProfile(id) {
2378
+ const p = getProfiles().find(p => p.id === id);
2379
+ if (!p) { toast("Profile not found", "error"); return; }
2380
+ S.gestureData = JSON.parse(JSON.stringify(p.gestureData));
2381
+ S.profileName = p.name;
2382
+ S.hand = p.hand || "right";
2383
+ S.currentProfileId = p.id;
2384
+ S.roundsToWin = parseInt(document.getElementById("optRounds").value);
2385
+ S.detectionMs = parseInt(document.getElementById("optDetection").value);
2386
+
2387
+ // Try to load cached model data from separate storage
2388
+ const cachedModel = loadModelData(p.id);
2389
+ if (cachedModel && cachedModel.type) {
2390
+ Model.deserialize(cachedModel);
2391
+ S.modelTrained = true;
2392
+ S.trainAccuracy = cachedModel.meta?.train_accuracy || 1;
2393
+ toast(`Loaded "${p.name}" — ready to play!`, "success");
2394
+ if (!S.wsConnected) connect();
2395
+ startSensorViz();
2396
+ startGame();
2397
+ } else {
2398
+ // No cached model — retrain from gesture data
2399
+ toast(`Loaded "${p.name}", training model...`, "info");
2400
+ if (!S.wsConnected) connect();
2401
+ startSensorViz();
2402
+ showScreen("cal");
2403
+ S.curGestIdx = GESTURES.length - 1;
2404
+ try {
2405
+ const success = await trainModel();
2406
+ if (success) startGame();
2407
+ } catch (e) {
2408
+ console.error("Training failed:", e);
2409
+ toast("Training error: " + e.message, "error");
2410
+ }
2411
+ }
2412
+ }
2413
+
2414
+ // Play with all profiles' training data merged into one model
2415
+ async function playAllCombined() {
2416
+ const profiles = getProfiles().filter(p =>
2417
+ p.gestureData && GESTURES.every(g => p.gestureData[g]?.samples?.length > 0)
2418
+ );
2419
+ if (!profiles.length) { toast("No trained profiles found", "error"); return; }
2420
+
2421
+ // Merge gesture data from all profiles
2422
+ S.gestureData = { rock: { samples: [] }, paper: { samples: [] }, scissors: { samples: [] } };
2423
+ let totalSamples = 0;
2424
+ profiles.forEach(p => {
2425
+ GESTURES.forEach(g => {
2426
+ if (p.gestureData[g]?.samples) {
2427
+ S.gestureData[g].samples.push(...JSON.parse(JSON.stringify(p.gestureData[g].samples)));
2428
+ totalSamples += p.gestureData[g].samples.length;
2429
+ }
2430
+ });
2431
+ });
2432
+
2433
+ S.profileName = `Combined (${profiles.length} profiles)`;
2434
+ S.hand = profiles[0].hand || "right";
2435
+ S.currentProfileId = null;
2436
+ S.roundsToWin = parseInt(document.getElementById("optRounds").value);
2437
+ S.detectionMs = parseInt(document.getElementById("optDetection").value);
2438
+
2439
+ const perGesture = GESTURES.map(g => S.gestureData[g].samples.length);
2440
+ toast(`Merging ${totalSamples} samples from ${profiles.length} profiles (${perGesture.join('/')})...`, "info");
2441
+
2442
+ if (!S.wsConnected) connect();
2443
+ startSensorViz();
2444
+ showScreen("cal");
2445
+ S.curGestIdx = GESTURES.length - 1;
2446
+
2447
+ try {
2448
+ const success = await trainModel();
2449
+ if (success) startGame();
2450
+ } catch (e) {
2451
+ console.error("Combined training failed:", e);
2452
+ toast("Training error: " + e.message, "error");
2453
+ }
2454
+ }
2455
+
2456
+ function deleteProfile(id) {
2457
+ const profiles = getProfiles(), p = profiles.find(p => p.id === id);
2458
+ if (!p || !confirm(`Delete "${p.name}"?`)) return;
2459
+ removeModelData(p.id);
2460
+ storeProfiles(profiles.filter(p => p.id !== id));
2461
+ toast(`Deleted "${p.name}"`, "info");
2462
+ updateProfilesUI();
2463
+ }
2464
+
2465
+ // Edit profile: load saved calibration data and open calibration to add more samples
2466
+ function editProfile(id) {
2467
+ const p = getProfiles().find(p => p.id === id);
2468
+ if (!p) { toast("Profile not found", "error"); return; }
2469
+
2470
+ // Load profile data into state
2471
+ S.gestureData = JSON.parse(JSON.stringify(p.gestureData));
2472
+ S.profileName = p.name;
2473
+ S.hand = p.hand || "right";
2474
+ S.currentProfileId = p.id;
2475
+ S.samplesReq = parseInt(document.getElementById("optSamples").value);
2476
+ S.roundsToWin = parseInt(document.getElementById("optRounds").value);
2477
+ S.detectionMs = parseInt(document.getElementById("optDetection").value);
2478
+ S.curGestIdx = 0;
2479
+ S.modelTrained = false;
2480
+ Model.clear();
2481
+
2482
+ // Update hand toggle buttons to reflect the profile's hand
2483
+ document.querySelectorAll(".hand-btn").forEach(b => b.classList.remove("selected"));
2484
+ const handBtn = document.querySelector(`.hand-btn[data-hand="${S.hand}"]`);
2485
+ if (handBtn) handBtn.classList.add("selected");
2486
+
2487
+ // Update profile name input
2488
+ document.getElementById("profileNameInput").value = S.profileName;
2489
+
2490
+ if (!S.wsConnected) connect();
2491
+ document.getElementById("trainPanel").classList.remove("active");
2492
+ showScreen("cal");
2493
+ updateCalUI();
2494
+ startSensorViz();
2495
+
2496
+ const total = GESTURES.reduce((n, g) => n + (S.gestureData[g]?.samples?.length || 0), 0);
2497
+ toast(`Editing "${p.name}" — ${total} samples loaded. Add more or train!`, "info");
2498
+ }
2499
+
2500
+ // ============ DEBUG MODE (Active Learning) ============
2501
+ let dbgAccCanvas, dbgAccCtx, dbgGyroCanvas, dbgGyroCtx;
2502
+ let debugRecogCounter = 0;
2503
+
2504
+ async function enterDebugMode(profileId) {
2505
+ const p = getProfiles().find(p => p.id === profileId);
2506
+ if (!p) { toast("Profile not found", "error"); return; }
2507
+
2508
+ // Load profile data into state
2509
+ S.gestureData = JSON.parse(JSON.stringify(p.gestureData));
2510
+ S.profileName = p.name;
2511
+ S.hand = p.hand || "right";
2512
+ S.currentProfileId = p.id;
2513
+ S.detectionMs = parseInt(document.getElementById("optDetection").value);
2514
+
2515
+ // Reset debug session stats
2516
+ S.debugCaptures = 0;
2517
+ S.debugCorrect = 0;
2518
+ S.debugNewSamples = 0;
2519
+ S.debugIsCapturing = false;
2520
+ S.debugCapturedResult = null;
2521
+ S.debugPhase = 'live';
2522
+
2523
+ // Load model
2524
+ const cachedModel = loadModelData(p.id);
2525
+ if (cachedModel && cachedModel.type) {
2526
+ Model.deserialize(cachedModel);
2527
+ S.modelTrained = true;
2528
+ S.trainAccuracy = cachedModel.meta?.train_accuracy || 1;
2529
+ } else {
2530
+ // Need to train from gesture data first
2531
+ toast(`Training model for "${p.name}"...`, "info");
2532
+ if (!S.wsConnected) connect();
2533
+ showScreen("debug");
2534
+ startDebugSensorViz();
2535
+ const success = await trainModel('debugTrain');
2536
+ if (!success) { toast("Training failed", "error"); showScreen("welcome"); return; }
2537
+ updateDebugUI();
2538
+ return;
2539
+ }
2540
+
2541
+ if (!S.wsConnected) connect();
2542
+ showScreen("debug");
2543
+ startDebugSensorViz();
2544
+ updateDebugUI();
2545
+ toast(`Debug mode: ${p.name}`, "info");
2546
+ }
2547
+
2548
+ function startDebugSensorViz() {
2549
+ dbgAccCanvas = document.getElementById("canvasAccDebug");
2550
+ dbgAccCtx = dbgAccCanvas?.getContext("2d");
2551
+ dbgGyroCanvas = document.getElementById("canvasGyroDebug");
2552
+ dbgGyroCtx = dbgGyroCanvas?.getContext("2d");
2553
+ [dbgAccCanvas, dbgGyroCanvas].forEach(c => {
2554
+ if (c) { c.width = c.offsetWidth * devicePixelRatio; c.height = c.offsetHeight * devicePixelRatio; }
2555
+ });
2556
+ if (!vizRunning) { vizRunning = true; requestAnimationFrame(renderLoop); }
2557
+ }
2558
+
2559
+ function updateDebugSensorValues() {
2560
+ setEl("dbgModeAccX", S.curAcc[0].toFixed(2));
2561
+ setEl("dbgModeAccY", S.curAcc[1].toFixed(2));
2562
+ setEl("dbgModeAccZ", S.curAcc[2].toFixed(2));
2563
+ setEl("dbgModeGyroX", S.curGyro[0].toFixed(2));
2564
+ setEl("dbgModeGyroY", S.curGyro[1].toFixed(2));
2565
+ setEl("dbgModeGyroZ", S.curGyro[2].toFixed(2));
2566
+ }
2567
+
2568
+ function updateDebugLivePrediction() {
2569
+ debugRecogCounter++;
2570
+ if (debugRecogCounter < 5) return; // throttle ~12fps
2571
+ debugRecogCounter = 0;
2572
+
2573
+ if (!S.modelTrained) {
2574
+ setEl("debugLiveIcon", "?");
2575
+ setEl("debugLiveName", "No model loaded");
2576
+ setEl("debugLiveConf", "-");
2577
+ return;
2578
+ }
2579
+
2580
+ const r = predictGesture(S.inferBuf);
2581
+ const colors = { rock: "var(--error)", paper: "var(--primary)", scissors: "var(--purple)" };
2582
+
2583
+ if (r.gesture && r.confidence > 0.4) {
2584
+ setEl("debugLiveIcon", ICONS[r.gesture]);
2585
+ setEl("debugLiveName", INFO[r.gesture].name);
2586
+ const confEl = document.getElementById("debugLiveConf");
2587
+ if (confEl) {
2588
+ confEl.textContent = `${(r.confidence * 100).toFixed(0)}%`;
2589
+ confEl.style.color = r.confidence > 0.7 ? "var(--success)" : r.confidence > 0.5 ? "var(--primary)" : "var(--warning)";
2590
+ }
2591
+ } else {
2592
+ setEl("debugLiveIcon", "?");
2593
+ setEl("debugLiveName", "Unknown");
2594
+ const confEl = document.getElementById("debugLiveConf");
2595
+ if (confEl) { confEl.textContent = `${(r.confidence * 100).toFixed(0)}%`; confEl.style.color = "var(--text-secondary)"; }
2596
+ }
2597
+
2598
+ // Update probability bars
2599
+ const barsEl = document.getElementById("debugProbBars");
2600
+ if (barsEl) {
2601
+ barsEl.innerHTML = GESTURES.map((g, i) => {
2602
+ const pct = ((r.probs[i] || 0) * 100).toFixed(0);
2603
+ const isTop = r.gesture === g;
2604
+ return `<div class="debug-prob-row">
2605
+ <div class="debug-prob-label"><span>${ICONS[g]}</span> <span>${INFO[g].name}</span></div>
2606
+ <div class="debug-prob-bar-bg">
2607
+ <div class="debug-prob-bar-fill" style="width:${pct}%; background:${colors[g]}; ${isTop ? 'opacity:1' : 'opacity:0.5'}"></div>
2608
+ </div>
2609
+ <div class="debug-prob-pct">${pct}%</div>
2610
+ </div>`;
2611
+ }).join("");
2612
+ }
2613
+ }
2614
+
2615
+ async function debugCapture() {
2616
+ if (S.debugIsCapturing || S.debugPhase === 'review') return;
2617
+
2618
+ const btn = document.getElementById("btnDebugCapture");
2619
+ S.debugPhase = 'capturing';
2620
+ S.debugIsCapturing = true;
2621
+
2622
+ btn.disabled = true;
2623
+ btn.textContent = "Hold gesture...";
2624
+ btn.classList.add("debug-capture-recording");
2625
+ Sfx.playShoot();
2626
+
2627
+ // Small lead-in for user to stabilize
2628
+ await sleep(300);
2629
+
2630
+ // Record 1.5s of sensor data (same mechanism as calibration)
2631
+ S.isRecording = true;
2632
+ S.recBuf = { imu: [], snc: [] };
2633
+ await sleep(1500);
2634
+ S.isRecording = false;
2635
+
2636
+ btn.textContent = "Capture Gesture";
2637
+ btn.classList.remove("debug-capture-recording");
2638
+ btn.disabled = false;
2639
+ Sfx.playRecordStop();
2640
+
2641
+ // Need enough data
2642
+ if (S.recBuf.imu.length < 10 && S.recBuf.snc.length < 10) {
2643
+ toast("Not enough sensor data. Check connection.", "error");
2644
+ S.debugIsCapturing = false;
2645
+ S.debugPhase = 'live';
2646
+ return;
2647
+ }
2648
+
2649
+ // Build merged buffer: recBuf stores separate {type:"acc"} and {type:"gyro"} entries
2650
+ // predictGesture expects {imu: [{acc, gyro}], snc: [[c1,c2,c3]]}
2651
+ const accEntries = S.recBuf.imu.filter(d => d.type === "acc");
2652
+ const gyroEntries = S.recBuf.imu.filter(d => d.type === "gyro");
2653
+ const mergedBuf = {
2654
+ imu: accEntries.map((a, i) => ({
2655
+ acc: [...a.values],
2656
+ gyro: gyroEntries[i] ? [...gyroEntries[i].values] : [0, 0, 0]
2657
+ })),
2658
+ snc: S.recBuf.snc.map(d => [...d.values])
2659
+ };
2660
+
2661
+ // Run prediction on captured buffer
2662
+ const result = predictGesture(mergedBuf);
2663
+
2664
+ // Store for review
2665
+ S.debugCapturedResult = {
2666
+ gesture: result.gesture,
2667
+ confidence: result.confidence,
2668
+ probs: result.probs,
2669
+ recBuf: JSON.parse(JSON.stringify(S.recBuf))
2670
+ };
2671
+
2672
+ S.debugIsCapturing = false;
2673
+ S.debugPhase = 'review';
2674
+ showDebugReview(result);
2675
+ }
2676
+
2677
+ function showDebugReview(result) {
2678
+ document.getElementById("debugCaptureRow").style.display = "none";
2679
+ document.getElementById("debugReviewPanel").style.display = "block";
2680
+
2681
+ const icon = result.gesture ? ICONS[result.gesture] : "?";
2682
+ const name = result.gesture ? INFO[result.gesture].name : "Unknown";
2683
+ setEl("debugReviewIcon", icon);
2684
+ setEl("debugReviewName", name);
2685
+ setEl("debugReviewConf", `${(result.confidence * 100).toFixed(0)}%`);
2686
+
2687
+ // Pre-highlight the predicted gesture button
2688
+ document.querySelectorAll(".debug-gesture-btn").forEach(btn => {
2689
+ btn.classList.remove("correct", "incorrect", "selected-correct");
2690
+ if (btn.dataset.gesture === result.gesture) {
2691
+ btn.classList.add("correct");
2692
+ }
2693
+ });
2694
+ }
2695
+
2696
+ function debugConfirmGesture(actualGesture) {
2697
+ if (S.debugPhase !== 'review' || !S.debugCapturedResult) return;
2698
+
2699
+ const predicted = S.debugCapturedResult.gesture;
2700
+ const isCorrect = predicted === actualGesture;
2701
+
2702
+ S.debugCaptures++;
2703
+ if (isCorrect) { S.debugCorrect++; Sfx.playWin(); }
2704
+ else { Sfx.playLose(); }
2705
+
2706
+ // Visual feedback
2707
+ document.querySelectorAll(".debug-gesture-btn").forEach(btn => {
2708
+ btn.classList.remove("correct", "incorrect", "selected-correct");
2709
+ if (btn.dataset.gesture === actualGesture) btn.classList.add("selected-correct");
2710
+ if (btn.dataset.gesture === predicted && !isCorrect) btn.classList.add("incorrect");
2711
+ });
2712
+
2713
+ // Active learning: save sample to gestureData under the user's label
2714
+ const recBuf = S.debugCapturedResult.recBuf;
2715
+ const stab = calcStability(recBuf);
2716
+
2717
+ S.gestureData[actualGesture].samples.push({
2718
+ timestamp: new Date().toISOString(),
2719
+ stability: stab,
2720
+ source: 'debug',
2721
+ imu_data: {
2722
+ accelerometer: recBuf.imu.filter(d => d.type === "acc").map(d => d.values),
2723
+ gyroscope: recBuf.imu.filter(d => d.type === "gyro").map(d => d.values)
2724
+ },
2725
+ snc_data: { channels: recBuf.snc.map(d => d.values) }
2726
+ });
2727
+
2728
+ S.debugNewSamples++;
2729
+
2730
+ if (isCorrect) {
2731
+ toast(`Correct! Sample added to ${INFO[actualGesture].name}`, "success");
2732
+ } else {
2733
+ toast(`Corrected: was ${predicted ? INFO[predicted].name : 'Unknown'} → labeled ${INFO[actualGesture].name}`, "info");
2734
+ }
2735
+
2736
+ updateDebugStats();
2737
+ updateDebugSampleCounts();
2738
+
2739
+ // Show retrain button if enough new samples
2740
+ if (S.debugNewSamples >= 3) {
2741
+ document.getElementById("debugRetrainRow").style.display = "block";
2742
+ setEl("debugRetrainCount", S.debugNewSamples);
2743
+ }
2744
+
2745
+ // Return to live mode after brief pause
2746
+ setTimeout(() => {
2747
+ S.debugPhase = 'live';
2748
+ S.debugCapturedResult = null;
2749
+ document.getElementById("debugReviewPanel").style.display = "none";
2750
+ document.getElementById("debugCaptureRow").style.display = "block";
2751
+ }, 1200);
2752
+ }
2753
+
2754
+ async function debugRetrain() {
2755
+ const btn = document.getElementById("btnDebugRetrain");
2756
+ btn.disabled = true;
2757
+ btn.textContent = "Retraining...";
2758
+
2759
+ try {
2760
+ const success = await trainModel('debugTrain');
2761
+ if (success) {
2762
+ S.debugNewSamples = 0;
2763
+ document.getElementById("debugRetrainRow").style.display = "none";
2764
+ // Auto-save the profile with new samples + model
2765
+ try { autoSaveProfile(); } catch (e) {
2766
+ console.error("Debug auto-save failed:", e);
2767
+ toast("Couldn't save profile (storage may be full)", "warning");
2768
+ }
2769
+ updateDebugSampleCounts();
2770
+ toast("Model retrained! Predictions should improve.", "success");
2771
+ }
2772
+ } catch (e) {
2773
+ toast("Retrain error: " + e.message, "error");
2774
+ }
2775
+
2776
+ btn.disabled = false;
2777
+ btn.textContent = `Retrain with ${S.debugNewSamples} new samples`;
2778
+ }
2779
+
2780
+ function updateDebugStats() {
2781
+ setEl("debugStatCaptures", S.debugCaptures);
2782
+ setEl("debugStatCorrect", S.debugCorrect);
2783
+ setEl("debugStatAccuracy", S.debugCaptures > 0 ? `${Math.round(S.debugCorrect / S.debugCaptures * 100)}%` : "-");
2784
+ setEl("debugStatNew", S.debugNewSamples);
2785
+ }
2786
+
2787
+ function updateDebugSampleCounts() {
2788
+ GESTURES.forEach(g => {
2789
+ const id = "debugSamples" + g.charAt(0).toUpperCase() + g.slice(1);
2790
+ setEl(id, S.gestureData[g]?.samples?.length || 0);
2791
+ });
2792
+ }
2793
+
2794
+ function updateDebugUI() {
2795
+ const handIcon = S.hand === 'left' ? '🫲' : '🫱';
2796
+ setEl("debugProfileBadge", `${handIcon} ${S.profileName} · ${S.hand} hand`);
2797
+ updateDebugStats();
2798
+ updateDebugSampleCounts();
2799
+ document.getElementById("debugReviewPanel").style.display = "none";
2800
+ document.getElementById("debugCaptureRow").style.display = "block";
2801
+ document.getElementById("debugRetrainRow").style.display = "none";
2802
+ document.getElementById("debugTrainPanel").classList.remove("active");
2803
+ }
2804
+
2805
+ // Render saved profiles on welcome screen
2806
+ function updateProfilesUI() {
2807
+ const profiles = getProfiles();
2808
+ const section = document.getElementById("savedProfilesSection");
2809
+ const list = document.getElementById("savedProfilesList");
2810
+ if (!profiles.length) { section.style.display = "none"; return; }
2811
+ section.style.display = "block";
2812
+ list.innerHTML = profiles.map(p => {
2813
+ const cached = loadModelData(p.id);
2814
+ const modelType = cached?.type === 'rf' ? 'Random Forest' : cached?.type === 'mlp' ? 'MLP' : 'will train';
2815
+ const samples = (p.samplesPerGesture || []).join('/') || '?';
2816
+ const handIcon = p.hand === 'left' ? '🫲' : '🫱';
2817
+ return `<div class="profile-item" onclick="loadProfile('${p.id}')">
2818
+ <div>
2819
+ <div class="profile-name">${handIcon} ${p.name}</div>
2820
+ <div class="profile-meta">${p.hand || 'right'} hand · ${modelType} · ${samples} samples</div>
2821
+ </div>
2822
+ <div style="display:flex; gap:6px; align-items:center;">
2823
+ <span class="profile-play" style="cursor:pointer;" title="Play now">▶</span>
2824
+ <button class="btn-edit-sm" onclick="event.stopPropagation(); editProfile('${p.id}')" title="Add samples">✏️</button>
2825
+ ${cached?.type ? `<button class="btn-edit-sm" onclick="event.stopPropagation(); enterDebugMode('${p.id}')" title="Debug mode" style="background:rgba(168,85,247,0.15); border-color:var(--purple); color:var(--purple); font-size:0.7rem;">&#x1F52C;</button>` : ''}
2826
+ <button class="btn-danger-sm" onclick="event.stopPropagation(); deleteProfile('${p.id}')" title="Delete">&times;</button>
2827
+ </div>
2828
+ </div>`;
2829
+ }).join("");
2830
+ // Show "Play All Combined" if 1+ profiles have gesture data
2831
+ const playAllBtn = document.getElementById("btnPlayAll");
2832
+ const hasData = profiles.some(p => p.gestureData && GESTURES.every(g => p.gestureData[g]?.samples?.length > 0));
2833
+ if (playAllBtn) playAllBtn.style.display = hasData ? "inline-block" : "none";
2834
+ }
2835
+
2836
+ // Legacy compat: also render in old presets modal if still present
2837
+ function updatePresetsUI() { updateProfilesUI(); }
2838
+
2839
+ // ============ AUDIO ============
2840
+ const Sfx = {
2841
+ ctx: null, ok: true,
2842
+ init() { try { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch { this.ok = false; } },
2843
+ resume() { if (this.ctx?.state === "suspended") this.ctx.resume(); },
2844
+ tone(f, d, type="sine", vol=0.3) {
2845
+ if (!this.ok || !this.ctx) return; this.resume();
2846
+ const o = this.ctx.createOscillator(), g = this.ctx.createGain();
2847
+ o.connect(g); g.connect(this.ctx.destination);
2848
+ o.frequency.value = f; o.type = type;
2849
+ g.gain.setValueAtTime(vol, this.ctx.currentTime);
2850
+ g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + d);
2851
+ o.start(); o.stop(this.ctx.currentTime + d);
2852
+ },
2853
+ playCountdown(n) { this.tone({3:440,2:523,1:659}[n]||880, 0.15, "square", 0.2); },
2854
+ playShoot() { this.tone(880, 0.1, "square", 0.3); setTimeout(() => this.tone(1100, 0.15, "square", 0.3), 100); },
2855
+ playWin() { [523,659,784,1047].forEach((f,i) => setTimeout(() => this.tone(f, 0.2, "sine", 0.25), i*100)); },
2856
+ playLose() { [392,330,294,262].forEach((f,i) => setTimeout(() => this.tone(f, 0.25, "sawtooth", 0.15), i*150)); },
2857
+ playDraw() { this.tone(440, 0.3, "triangle", 0.2); },
2858
+ playRecordStop() { this.tone(880, 0.1, "sine", 0.2); },
2859
+ playVictoryFanfare() { [{f:523,d:.15},{f:659,d:.15},{f:784,d:.15},{f:1047,d:.3},{f:784,d:.15},{f:1047,d:.4}].reduce((t,n) => { setTimeout(() => this.tone(n.f,n.d,"sine",0.25), t); return t+n.d*1000; }, 0); },
2860
+ playDefeatSound() { [{f:392,d:.2},{f:349,d:.2},{f:330,d:.2},{f:262,d:.5}].reduce((t,n) => { setTimeout(() => this.tone(n.f,n.d,"sawtooth",0.15), t); return t+n.d*1000; }, 0); }
2861
+ };
2862
+
2863
+ // ============ UTILS ============
2864
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
2865
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
2866
+ function clamp01(v) { return Number.isFinite(v) ? clamp(v, 0, 1) : 0; }
2867
+ function toast(msg, type="info") {
2868
+ const t = document.getElementById("toast"); t.textContent = msg;
2869
+ t.className = `toast ${type} show`; setTimeout(() => t.classList.remove("show"), 3000);
2870
+ }
2871
+
2872
+ // ============ EVENT WIRING ============
2873
+ document.addEventListener("DOMContentLoaded", () => {
2874
+ Sfx.init(); updateProfilesUI(); connect(); checkStorage();
2875
+ syncToFolder(); // write existing profiles to project folder
2876
+ checkFolderProfiles(); // offer import if new user with no profiles
2877
+ });
2878
+
2879
+ // ============ EXPORT / IMPORT ============
2880
+ function exportData() {
2881
+ const profiles = getProfiles();
2882
+ if (!profiles.length) { toast("No profiles to export", "error"); return; }
2883
+ const bundle = { version: "v4", exportedAt: new Date().toISOString(), profiles: [], models: {} };
2884
+ profiles.forEach(p => {
2885
+ bundle.profiles.push(p);
2886
+ const model = loadModelData(p.id);
2887
+ if (model) bundle.models[p.id] = model;
2888
+ });
2889
+ const json = JSON.stringify(bundle);
2890
+ const blob = new Blob([json], { type: "application/json" });
2891
+ const url = URL.createObjectURL(blob);
2892
+ const a = document.createElement("a");
2893
+ a.href = url;
2894
+ a.download = `mudra-duel-profiles-${new Date().toISOString().slice(0,10)}.json`;
2895
+ a.click();
2896
+ URL.revokeObjectURL(url);
2897
+ toast(`Exported ${profiles.length} profiles (${(json.length/1024).toFixed(0)}KB)`, "success");
2898
+ }
2899
+
2900
+ function importData(file) {
2901
+ const reader = new FileReader();
2902
+ reader.onload = () => {
2903
+ try {
2904
+ const bundle = JSON.parse(reader.result);
2905
+ if (!bundle.profiles?.length) { toast("No profiles found in file", "error"); return; }
2906
+ const existing = getProfiles();
2907
+ const existingNames = new Set(existing.map(p => p.name.toLowerCase()));
2908
+ let added = 0, skipped = 0;
2909
+ bundle.profiles.forEach(p => {
2910
+ if (existingNames.has(p.name.toLowerCase())) { skipped++; return; }
2911
+ existing.push(p);
2912
+ existingNames.add(p.name.toLowerCase());
2913
+ if (bundle.models?.[p.id]) {
2914
+ storeModelData(p.id, bundle.models[p.id]);
2915
+ }
2916
+ added++;
2917
+ });
2918
+ storeProfiles(existing);
2919
+ updateProfilesUI();
2920
+ checkStorage();
2921
+ toast(`Imported ${added} profiles${skipped ? ` (${skipped} duplicates skipped)` : ''}`, "success");
2922
+ } catch (e) {
2923
+ console.error("Import error:", e);
2924
+ toast("Invalid file: " + e.message, "error");
2925
+ }
2926
+ };
2927
+ reader.readAsText(file);
2928
+ }
2929
+
2930
+ // ============ AUTO-DETECT FOLDER PROFILES ============
2931
+ async function checkFolderProfiles() {
2932
+ try {
2933
+ // Skip if user already has profiles (they're already synced)
2934
+ if (getProfiles().length > 0) return;
2935
+
2936
+ const res = await fetch('/profiles-files');
2937
+ if (!res.ok) return;
2938
+ const { files } = await res.json();
2939
+ if (!files || !files.length) return;
2940
+
2941
+ const banner = document.getElementById("folderImportBanner");
2942
+ const list = document.getElementById("folderFileList");
2943
+ const btnImport = document.getElementById("btnFolderImport");
2944
+ let selectedFile = null;
2945
+
2946
+ list.innerHTML = '';
2947
+ files.forEach(f => {
2948
+ const li = document.createElement("li");
2949
+ li.textContent = `📄 ${f}`;
2950
+ li.addEventListener("click", () => {
2951
+ list.querySelectorAll("li").forEach(el => el.classList.remove("selected"));
2952
+ li.classList.add("selected");
2953
+ selectedFile = f;
2954
+ btnImport.disabled = false;
2955
+ });
2956
+ list.appendChild(li);
2957
+ });
2958
+
2959
+ // Auto-select if only one file
2960
+ if (files.length === 1) {
2961
+ list.querySelector("li").classList.add("selected");
2962
+ selectedFile = files[0];
2963
+ btnImport.disabled = false;
2964
+ }
2965
+
2966
+ btnImport.onclick = async () => {
2967
+ if (!selectedFile) return;
2968
+ btnImport.disabled = true;
2969
+ btnImport.textContent = "Importing...";
2970
+ try {
2971
+ const fRes = await fetch(`/file/${encodeURIComponent(selectedFile)}`);
2972
+ if (!fRes.ok) throw new Error("Could not fetch file");
2973
+ const bundle = await fRes.json();
2974
+ if (!bundle.profiles?.length) { toast("No profiles found in file", "error"); return; }
2975
+ const existing = getProfiles();
2976
+ const existingNames = new Set(existing.map(p => p.name.toLowerCase()));
2977
+ let added = 0, skipped = 0;
2978
+ bundle.profiles.forEach(p => {
2979
+ if (existingNames.has(p.name.toLowerCase())) { skipped++; return; }
2980
+ existing.push(p);
2981
+ existingNames.add(p.name.toLowerCase());
2982
+ if (bundle.models?.[p.id]) storeModelData(p.id, bundle.models[p.id]);
2983
+ added++;
2984
+ });
2985
+ storeProfiles(existing);
2986
+ updateProfilesUI();
2987
+ checkStorage();
2988
+ toast(`Imported ${added} profiles from ${selectedFile}${skipped ? ` (${skipped} duplicates skipped)` : ''}`, "success");
2989
+ } catch (e) {
2990
+ console.error("Folder import error:", e);
2991
+ toast("Import failed: " + e.message, "error");
2992
+ }
2993
+ banner.style.display = "none";
2994
+ };
2995
+
2996
+ document.getElementById("btnFolderDismiss").onclick = () => {
2997
+ banner.style.display = "none";
2998
+ };
2999
+
3000
+ banner.style.display = "block";
3001
+ console.log(`[Folder] Found ${files.length} profile file(s): ${files.join(', ')}`);
3002
+ } catch (e) {
3003
+ // Server not available or not running via HTTP — silently skip
3004
+ console.log("[Folder] Auto-detect skipped (not served via HTTP or no files)");
3005
+ }
3006
+ }
3007
+
3008
+ // ============ AUTO-SYNC PROFILES TO FOLDER ============
3009
+ let _syncTimer = null;
3010
+ function syncToFolder() {
3011
+ // Debounce: wait 2s after last save to avoid rapid writes
3012
+ clearTimeout(_syncTimer);
3013
+ _syncTimer = setTimeout(_doSyncToFolder, 2000);
3014
+ }
3015
+ async function _doSyncToFolder() {
3016
+ try {
3017
+ const profiles = getProfiles();
3018
+ if (!profiles.length) return;
3019
+ const bundle = {
3020
+ version: "v4",
3021
+ exportedAt: new Date().toISOString(),
3022
+ autoSaved: true,
3023
+ profiles: profiles,
3024
+ models: {}
3025
+ };
3026
+ profiles.forEach(p => {
3027
+ const m = loadModelData(p.id);
3028
+ if (m) bundle.models[p.id] = m;
3029
+ });
3030
+ const res = await fetch('/save-profiles', {
3031
+ method: 'POST',
3032
+ headers: { 'Content-Type': 'application/json' },
3033
+ body: JSON.stringify(bundle)
3034
+ });
3035
+ if (res.ok) {
3036
+ console.log(`[Sync] Saved ${profiles.length} profiles to project folder`);
3037
+ }
3038
+ } catch (e) {
3039
+ // Not running via HTTP server — silently skip
3040
+ }
3041
+ }
3042
+
3043
+ function checkStorage() {
3044
+ const dot = document.getElementById("storageDot");
3045
+ const text = document.getElementById("storageText");
3046
+ try {
3047
+ // Test that localStorage actually works
3048
+ const testKey = "__storage_test__";
3049
+ localStorage.setItem(testKey, "1");
3050
+ localStorage.removeItem(testKey);
3051
+ // Measure usage
3052
+ let totalBytes = 0;
3053
+ for (let i = 0; i < localStorage.length; i++) {
3054
+ const key = localStorage.key(i);
3055
+ totalBytes += (key?.length || 0) + (localStorage.getItem(key)?.length || 0);
3056
+ }
3057
+ const profiles = getProfiles();
3058
+ dot.classList.add("connected");
3059
+ text.textContent = `Storage: ${profiles.length} profiles (${(totalBytes/1024).toFixed(0)}KB)`;
3060
+ console.log(`[Storage] OK — ${(totalBytes/1024).toFixed(0)}KB used, ${localStorage.length} keys, ${profiles.length} profiles`);
3061
+ profiles.forEach(p => console.log(` Profile: "${p.name}" (${p.id}), samples: ${p.samplesPerGesture}, model cached: ${!!loadModelData(p.id)}`));
3062
+ } catch (e) {
3063
+ dot.classList.remove("connected");
3064
+ text.textContent = "Storage: UNAVAILABLE";
3065
+ console.error("[Storage] localStorage not available:", e);
3066
+ toast("⚠ Storage not available — profiles won't persist. Try opening via http://127.0.0.1:8767/", "error");
3067
+ }
3068
+ }
3069
+
3070
+ document.getElementById("btnStartCal").addEventListener("click", startCalibration);
3071
+ document.getElementById("profileNameInput").addEventListener("keydown", e => { if (e.key === "Enter") startCalibration(); });
3072
+ document.getElementById("btnPlayAll").addEventListener("click", playAllCombined);
3073
+ document.getElementById("btnExportData").addEventListener("click", exportData);
3074
+ document.getElementById("btnImportData").addEventListener("click", () => document.getElementById("importFileInput").click());
3075
+ document.getElementById("importFileInput").addEventListener("change", (e) => {
3076
+ if (e.target.files[0]) { importData(e.target.files[0]); e.target.value = ""; }
3077
+ });
3078
+
3079
+ // Hand toggle
3080
+ document.querySelectorAll(".hand-btn").forEach(btn => {
3081
+ btn.addEventListener("click", () => {
3082
+ document.querySelectorAll(".hand-btn").forEach(b => b.classList.remove("selected"));
3083
+ btn.classList.add("selected");
3084
+ S.hand = btn.dataset.hand;
3085
+ });
3086
+ });
3087
+ document.getElementById("btnQuickPlay").addEventListener("click", quickPlay);
3088
+ document.getElementById("btnRecord").addEventListener("click", doRecord);
3089
+ document.getElementById("btnAddMore").addEventListener("click", () => toast("Record additional samples for better accuracy", "info"));
3090
+ document.getElementById("btnResetGesture").addEventListener("click", resetCurrentGesture);
3091
+ document.getElementById("btnNextGesture").addEventListener("click", nextGesture);
3092
+ document.getElementById("btnSkipCal").addEventListener("click", skipCalibration);
3093
+ // Profile auto-save replaces old manual preset save
3094
+
3095
+ // Recalibrate: keep existing samples, go back to cal screen to add more
3096
+ const doRecal = () => {
3097
+ S.curGestIdx = 0;
3098
+ S.modelTrained = false;
3099
+ Model.clear();
3100
+ document.getElementById("trainPanel").classList.remove("active");
3101
+ showScreen("cal");
3102
+ updateCalUI();
3103
+ startSensorViz();
3104
+ const total = GESTURES.reduce((n, g) => n + (S.gestureData[g]?.samples?.length || 0), 0);
3105
+ toast(`Add more samples (${total} existing kept)`, "info");
3106
+ };
3107
+ document.getElementById("btnRecalibrate").addEventListener("click", doRecal);
3108
+ document.getElementById("btnRecalResults").addEventListener("click", doRecal);
3109
+
3110
+ document.getElementById("btnStartGame").addEventListener("click", startGame);
3111
+ document.getElementById("btnStartAnyway").addEventListener("click", startGame);
3112
+ document.getElementById("btnTrainNow").addEventListener("click", async () => {
3113
+ try {
3114
+ const success = await trainModel();
3115
+ if (success) { await sleep(500); startGame(); }
3116
+ } catch (e) {
3117
+ console.error("Training failed:", e);
3118
+ toast("Training error: " + e.message, "error");
3119
+ }
3120
+ });
3121
+ document.getElementById("btnNextRound").addEventListener("click", () => {
3122
+ document.getElementById("gameCorrectionPanel").style.display = "none";
3123
+ document.getElementById("btnWrongGesture").style.display = "none";
3124
+ if (S._pendingMatchResult) {
3125
+ showResults(S._pendingMatchResult);
3126
+ S._pendingMatchResult = null;
3127
+ } else {
3128
+ startRound();
3129
+ }
3130
+ });
3131
+ document.getElementById("btnWrongGesture").addEventListener("click", showGameCorrection);
3132
+ document.querySelectorAll(".game-correct-btn").forEach(btn => {
3133
+ btn.addEventListener("click", () => correctGameGesture(btn.dataset.gesture));
3134
+ });
3135
+ document.getElementById("btnQuitGame").addEventListener("click", () => {
3136
+ // Save any corrections before quitting
3137
+ if (S.debugNewSamples > 0) {
3138
+ try { autoSaveProfile(); } catch (e) { console.warn("Save on quit failed:", e); }
3139
+ }
3140
+ stopGame(); showScreen("welcome");
3141
+ });
3142
+ document.getElementById("btnPlayAgain").addEventListener("click", startGame);
3143
+ document.getElementById("btnMainMenu").addEventListener("click", () => { stopGame(); showScreen("welcome"); });
3144
+
3145
+ document.getElementById("fabHelp").addEventListener("click", () => openModal("helpModal"));
3146
+ document.getElementById("btnCloseHelp").addEventListener("click", () => closeModal("helpModal"));
3147
+ // Old preset modals kept for compat but no longer primary UX
3148
+ document.getElementById("btnClosePresets")?.addEventListener("click", () => closeModal("presetsModal"));
3149
+ document.getElementById("btnCloseSavePreset")?.addEventListener("click", () => closeModal("savePresetModal"));
3150
+ document.getElementById("btnDoSavePreset")?.addEventListener("click", () => { autoSaveProfile(); closeModal("savePresetModal"); });
3151
+ document.getElementById("fabDebug").addEventListener("click", toggleDebug);
3152
+ document.getElementById("btnCloseDebug").addEventListener("click", toggleDebug);
3153
+ document.getElementById("presetNameInput")?.addEventListener("keydown", e => { if (e.key === "Enter") { autoSaveProfile(); closeModal("savePresetModal"); } });
3154
+
3155
+ // Debug Mode event wiring
3156
+ document.getElementById("btnDebugCapture").addEventListener("click", debugCapture);
3157
+ document.getElementById("btnDebugRetrain").addEventListener("click", debugRetrain);
3158
+ document.getElementById("btnDebugBack").addEventListener("click", () => {
3159
+ // Save any new samples before leaving
3160
+ if (S.debugNewSamples > 0) {
3161
+ try { autoSaveProfile(); } catch (e) { console.warn("Save on exit failed:", e); }
3162
+ }
3163
+ showScreen("welcome");
3164
+ });
3165
+ document.getElementById("btnDebugPlay").addEventListener("click", () => startGame());
3166
+ document.querySelectorAll(".debug-gesture-btn").forEach(btn => {
3167
+ btn.addEventListener("click", () => debugConfirmGesture(btn.dataset.gesture));
3168
+ });
3169
+
3170
+ document.addEventListener("keydown", e => {
3171
+ const debugActive = document.getElementById("screen-debug")?.classList.contains("active");
3172
+ if (e.code === "Space" && document.getElementById("screen-cal")?.classList.contains("active")) { e.preventDefault(); doRecord(); }
3173
+ if (e.code === "Space" && debugActive && S.debugPhase === 'live') { e.preventDefault(); debugCapture(); }
3174
+ if (e.code === "Enter") { const btn = document.getElementById("btnNextRound"); if (btn?.style.display !== "none") startRound(); }
3175
+ if (e.key === "?") openModal("helpModal");
3176
+ if (e.code === "Escape") { closeModal("helpModal"); closeModal("presetsModal"); closeModal("savePresetModal"); }
3177
+ if (e.key === "d" && e.ctrlKey) { e.preventDefault(); toggleDebug(); }
3178
+ // Debug mode: quick gesture selection during review (1/2/3 or r/p/s)
3179
+ if (debugActive && S.debugPhase === 'review') {
3180
+ if (e.key === '1' || e.key.toLowerCase() === 'r') { e.preventDefault(); debugConfirmGesture('rock'); }
3181
+ if (e.key === '2' || e.key.toLowerCase() === 'p') { e.preventDefault(); debugConfirmGesture('paper'); }
3182
+ if (e.key === '3' || e.key.toLowerCase() === 's') { e.preventDefault(); debugConfirmGesture('scissors'); }
3183
+ }
3184
+ if (S.quickMode && S.isPlaying) {
3185
+ if (e.key.toLowerCase() === "r") { quickPlayGesture = "rock"; toast("Rock!", "info"); }
3186
+ if (e.key.toLowerCase() === "p") { quickPlayGesture = "paper"; toast("Paper!", "info"); }
3187
+ if (e.key.toLowerCase() === "s") { quickPlayGesture = "scissors"; toast("Scissors!", "info"); }
3188
+ }
3189
+ });
3190
+
3191
+ document.addEventListener("click", () => Sfx.resume(), { once: true });
3192
+ document.addEventListener("touchstart", () => Sfx.resume(), { once: true });
3193
+ window.addEventListener("resize", resizeCanvases);
3194
+
3195
+ const cfStyle = document.createElement("style");
3196
+ cfStyle.textContent = `@keyframes confetti-fall { 0% { transform: translateY(0) rotate(0deg); opacity:1; } 100% { transform: translateY(100vh) rotate(720deg); opacity:0; } }`;
3197
+ document.head.appendChild(cfStyle);
3198
+ </script>
3199
+ </body>
3200
+ </html>