openvoiceui 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 (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * BaseFace — abstract base class for face/avatar modules (ADR-009: simple manager pattern)
3
+ *
4
+ * All face implementations extend this class and override the lifecycle methods.
5
+ * The face manager loads faces from manifest.json and swaps them at runtime.
6
+ *
7
+ * Usage:
8
+ * import { BaseFace } from './BaseFace.js';
9
+ *
10
+ * class MyFace extends BaseFace {
11
+ * init(container) { ... }
12
+ * setMood(mood) { ... }
13
+ * destroy() { ... }
14
+ * }
15
+ *
16
+ * Events emitted on eventBus:
17
+ * 'face:mood' { mood: string } — mood changed
18
+ * 'face:ready' { id: string } — face initialized
19
+ * 'face:changed' { from: string, to: string } — face swapped
20
+ */
21
+
22
+ import { eventBus } from '../core/EventBus.js';
23
+
24
+ export const VALID_MOODS = ['neutral', 'happy', 'sad', 'angry', 'thinking', 'surprised', 'listening'];
25
+
26
+ /**
27
+ * @abstract
28
+ */
29
+ export class BaseFace {
30
+ /**
31
+ * @param {string} id unique face ID from manifest (e.g. 'eyes', 'orb')
32
+ */
33
+ constructor(id) {
34
+ if (new.target === BaseFace) {
35
+ throw new Error('BaseFace is abstract — extend it, do not instantiate it directly');
36
+ }
37
+ /** @type {string} */
38
+ this.id = id;
39
+ /** @type {string} */
40
+ this.currentMood = 'neutral';
41
+ /** @type {HTMLElement|null} */
42
+ this.container = null;
43
+ /** @protected */
44
+ this._initialized = false;
45
+ }
46
+
47
+ /**
48
+ * Initialize the face inside the given container element.
49
+ * Called once when the face is first activated.
50
+ * @param {HTMLElement} container the .face-box element
51
+ * @abstract
52
+ */
53
+ init(container) {
54
+ throw new Error(`${this.constructor.name}.init() not implemented`);
55
+ }
56
+
57
+ /**
58
+ * Set the face's emotional state.
59
+ * @param {string} mood one of VALID_MOODS
60
+ * @abstract
61
+ */
62
+ setMood(mood) {
63
+ throw new Error(`${this.constructor.name}.setMood() not implemented`);
64
+ }
65
+
66
+ /**
67
+ * Trigger a blink animation (optional — no-op by default).
68
+ */
69
+ blink() {
70
+ // optional override
71
+ }
72
+
73
+ /**
74
+ * React to audio amplitude (0-1). Used for speaking animations.
75
+ * @param {number} amplitude 0.0 (silent) to 1.0 (loud)
76
+ */
77
+ setAmplitude(amplitude) {
78
+ // optional override
79
+ }
80
+
81
+ /**
82
+ * Called when the face is deactivated / replaced by another face.
83
+ * Clean up timers, animation frames, DOM mutations.
84
+ * @abstract
85
+ */
86
+ destroy() {
87
+ throw new Error(`${this.constructor.name}.destroy() not implemented`);
88
+ }
89
+
90
+ // ── protected helpers ─────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Normalize and validate a mood string.
94
+ * @param {string} mood
95
+ * @returns {string} valid mood, falls back to 'neutral'
96
+ * @protected
97
+ */
98
+ _normalizeMood(mood) {
99
+ return VALID_MOODS.includes(mood) ? mood : 'neutral';
100
+ }
101
+
102
+ /**
103
+ * Emit a face:mood event on the shared eventBus.
104
+ * @param {string} mood
105
+ * @protected
106
+ */
107
+ _emitMood(mood) {
108
+ this.currentMood = mood;
109
+ eventBus.emit('face:mood', { mood, faceId: this.id });
110
+ }
111
+
112
+ /**
113
+ * Emit face:ready after init completes.
114
+ * @protected
115
+ */
116
+ _emitReady() {
117
+ this._initialized = true;
118
+ eventBus.emit('face:ready', { id: this.id });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * FaceManager — loads the active face from manifest, handles swapping.
124
+ *
125
+ * Usage:
126
+ * import { faceManager } from './BaseFace.js';
127
+ *
128
+ * await faceManager.load('eyes'); // activate EyeFace
129
+ * faceManager.setMood('happy');
130
+ * faceManager.blink();
131
+ * await faceManager.load('orb'); // swap to OrbFace
132
+ */
133
+ class FaceManager {
134
+ constructor() {
135
+ /** @type {BaseFace|null} */
136
+ this._active = null;
137
+ /** @type {HTMLElement|null} */
138
+ this._container = null;
139
+ /** @type {Record<string, () => Promise<BaseFace>>} */
140
+ this._registry = {};
141
+ /** @type {Object|null} loaded manifest */
142
+ this._manifest = null;
143
+ }
144
+
145
+ /**
146
+ * Point the manager at a container element and load the manifest.
147
+ * @param {HTMLElement} container .face-box DOM element
148
+ * @param {string} [manifestUrl] URL to manifest.json
149
+ */
150
+ async init(container, manifestUrl = '/src/face/manifest.json') {
151
+ this._container = container;
152
+ try {
153
+ const res = await fetch(manifestUrl);
154
+ if (res.ok) {
155
+ this._manifest = await res.json();
156
+ }
157
+ } catch (err) {
158
+ console.warn('[FaceManager] Could not load manifest:', err.message);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Register a face factory.
164
+ * @param {string} id
165
+ * @param {() => Promise<BaseFace>} factory async factory that returns a face instance
166
+ */
167
+ register(id, factory) {
168
+ this._registry[id] = factory;
169
+ }
170
+
171
+ /**
172
+ * Activate a face by ID. Destroys the current face first.
173
+ * @param {string} id
174
+ */
175
+ async load(id) {
176
+ if (!this._registry[id]) {
177
+ console.warn(`[FaceManager] Unknown face id: ${id}`);
178
+ return;
179
+ }
180
+
181
+ const previousId = this._active?.id ?? null;
182
+
183
+ // Destroy current face
184
+ if (this._active) {
185
+ this._active.destroy();
186
+ this._active = null;
187
+ }
188
+
189
+ // Create & init new face
190
+ const face = await this._registry[id]();
191
+ if (this._container) {
192
+ face.init(this._container);
193
+ }
194
+ this._active = face;
195
+
196
+ // Persist selection to server profile
197
+ const profileId = window.providerManager?._activeProfileId || 'default';
198
+ fetch('/api/profiles/' + profileId, {
199
+ method: 'PUT',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ ui: { face_mode: id } })
202
+ }).catch(e => console.warn('Failed to save face to profile:', e));
203
+ if (window._serverProfile) {
204
+ if (!window._serverProfile.ui) window._serverProfile.ui = {};
205
+ window._serverProfile.ui.face_mode = id;
206
+ }
207
+
208
+ eventBus.emit('face:changed', { from: previousId, to: id });
209
+ }
210
+
211
+ /**
212
+ * Load the previously saved face (or a default).
213
+ * @param {string} [defaultId]
214
+ */
215
+ async loadSaved(defaultId = 'eyes') {
216
+ let saved = defaultId;
217
+ try { saved = window._serverProfile?.ui?.face_mode || defaultId; } catch (_) {}
218
+ await this.load(saved);
219
+ }
220
+
221
+ /**
222
+ * Delegate setMood to the active face.
223
+ * @param {string} mood
224
+ */
225
+ setMood(mood) {
226
+ this._active?.setMood(mood);
227
+ }
228
+
229
+ /** Delegate blink to the active face. */
230
+ blink() {
231
+ this._active?.blink();
232
+ }
233
+
234
+ /**
235
+ * Delegate amplitude to the active face.
236
+ * @param {number} amplitude
237
+ */
238
+ setAmplitude(amplitude) {
239
+ this._active?.setAmplitude(amplitude);
240
+ }
241
+
242
+ /** @returns {string|null} */
243
+ get activeFaceId() {
244
+ return this._active?.id ?? null;
245
+ }
246
+
247
+ /** @returns {string} */
248
+ get currentMood() {
249
+ return this._active?.currentMood ?? 'neutral';
250
+ }
251
+
252
+ /** @returns {Array<{id, name, description}>} faces from manifest */
253
+ get availableFaces() {
254
+ return this._manifest?.faces ?? [];
255
+ }
256
+ }
257
+
258
+ // Singleton
259
+ export const faceManager = new FaceManager();
@@ -0,0 +1,208 @@
1
+ /**
2
+ * EyeFace — animated eye face extracted from index.html FaceModule
3
+ *
4
+ * Renders two eyes with:
5
+ * - Mood-based eyelid animations (happy, sad, angry, thinking, surprised, listening)
6
+ * - Mouse-tracking pupil movement
7
+ * - Random autonomous blinking and looking behavior
8
+ *
9
+ * Usage (via FaceManager):
10
+ * import { faceManager } from './BaseFace.js';
11
+ * import { EyeFace } from './EyeFace.js';
12
+ *
13
+ * faceManager.register('eyes', () => new EyeFace());
14
+ * await faceManager.loadSaved('eyes');
15
+ *
16
+ * Or standalone:
17
+ * import { EyeFace } from './EyeFace.js';
18
+ * const face = new EyeFace();
19
+ * face.init(document.querySelector('.face-box'));
20
+ * face.setMood('happy');
21
+ */
22
+
23
+ import { BaseFace, VALID_MOODS } from './BaseFace.js';
24
+ import { eventBus } from '../core/EventBus.js';
25
+
26
+ export class EyeFace extends BaseFace {
27
+ constructor() {
28
+ super('eyes');
29
+
30
+ /** @type {HTMLElement|null} */
31
+ this._leftEye = null;
32
+ /** @type {HTMLElement|null} */
33
+ this._rightEye = null;
34
+ /** @type {HTMLElement|null} */
35
+ this._leftPupil = null;
36
+ /** @type {HTMLElement|null} */
37
+ this._rightPupil = null;
38
+
39
+ /** @private timer IDs for cleanup */
40
+ this._blinkTimer = null;
41
+ this._lookTimer = null;
42
+ this._lastMouseMove = Date.now();
43
+
44
+ /** @private bound handlers for removeEventListener */
45
+ this._onMouseMove = this._handleMouseMove.bind(this);
46
+
47
+ /** @private eventBus unsub functions */
48
+ this._unsubs = [];
49
+ }
50
+
51
+ /**
52
+ * @param {HTMLElement} container .face-box element
53
+ * @override
54
+ */
55
+ init(container) {
56
+ this.container = container;
57
+
58
+ // Grab existing DOM elements (created by index.html shell)
59
+ this._leftEye = document.getElementById('left-eye');
60
+ this._rightEye = document.getElementById('right-eye');
61
+ this._leftPupil = document.getElementById('left-pupil-container');
62
+ this._rightPupil = document.getElementById('right-pupil-container');
63
+
64
+ if (!this._leftEye || !this._rightEye) {
65
+ console.warn('[EyeFace] Eye elements not found in DOM');
66
+ return;
67
+ }
68
+
69
+ // Show eyes in case another face had hidden them
70
+ const eyesContainer = container.querySelector('.eyes-container');
71
+ if (eyesContainer) eyesContainer.style.display = 'flex';
72
+
73
+ // Subscribe to EventBus mood changes
74
+ this._unsubs.push(
75
+ eventBus.on('tts:start', () => this.setMood('listening')),
76
+ eventBus.on('tts:stop', () => this.setMood('neutral')),
77
+ eventBus.on('stt:start', () => this.setMood('listening')),
78
+ eventBus.on('stt:stop', () => this.setMood('neutral')),
79
+ );
80
+
81
+ this._startRandomBehavior();
82
+ this._emitReady();
83
+ }
84
+
85
+ /**
86
+ * Set the eye mood by toggling CSS mood classes on the eye elements.
87
+ * @param {string} mood
88
+ * @override
89
+ */
90
+ setMood(mood) {
91
+ if (!this._leftEye || !this._rightEye) return;
92
+
93
+ mood = this._normalizeMood(mood);
94
+
95
+ // Remove all mood classes
96
+ VALID_MOODS.forEach(m => {
97
+ this._leftEye.classList.remove(m);
98
+ this._rightEye.classList.remove(m);
99
+ });
100
+
101
+ // Add new mood class (neutral has no class)
102
+ if (mood !== 'neutral') {
103
+ this._leftEye.classList.add(mood);
104
+ this._rightEye.classList.add(mood);
105
+ }
106
+
107
+ this._emitMood(mood);
108
+ }
109
+
110
+ /**
111
+ * Trigger a quick blink animation (~150ms).
112
+ * @override
113
+ */
114
+ blink() {
115
+ if (!this._leftEye || !this._rightEye) return;
116
+
117
+ this._leftEye.classList.add('blinking');
118
+ this._rightEye.classList.add('blinking');
119
+
120
+ setTimeout(() => {
121
+ this._leftEye.classList.remove('blinking');
122
+ this._rightEye.classList.remove('blinking');
123
+ }, 150);
124
+ }
125
+
126
+ /**
127
+ * Move pupils toward a screen coordinate.
128
+ * @param {number} x client X
129
+ * @param {number} y client Y
130
+ */
131
+ updateEyePosition(x, y) {
132
+ if (!this._leftPupil || !this._rightPupil) return;
133
+
134
+ const centerX = window.innerWidth / 2;
135
+ const centerY = window.innerHeight / 2;
136
+ const maxOffset = 15;
137
+
138
+ const offsetX = ((x - centerX) / centerX) * maxOffset;
139
+ const offsetY = ((y - centerY) / centerY) * maxOffset;
140
+
141
+ const transform = `translate(${offsetX}px, ${offsetY}px)`;
142
+ this._leftPupil.style.transform = transform;
143
+ this._rightPupil.style.transform = transform;
144
+ }
145
+
146
+ /**
147
+ * Remove all event listeners, timers, and DOM mutations.
148
+ * @override
149
+ */
150
+ destroy() {
151
+ // Clear timers
152
+ if (this._blinkTimer) { clearTimeout(this._blinkTimer); this._blinkTimer = null; }
153
+ if (this._lookTimer) { clearTimeout(this._lookTimer); this._lookTimer = null; }
154
+
155
+ // Remove mouse listener
156
+ document.removeEventListener('mousemove', this._onMouseMove);
157
+
158
+ // Unsubscribe from eventBus
159
+ this._unsubs.forEach(unsub => unsub());
160
+ this._unsubs = [];
161
+
162
+ // Clear all mood classes
163
+ if (this._leftEye && this._rightEye) {
164
+ VALID_MOODS.forEach(m => {
165
+ this._leftEye.classList.remove(m);
166
+ this._rightEye.classList.remove(m);
167
+ });
168
+ }
169
+
170
+ this._initialized = false;
171
+ }
172
+
173
+ // ── private ───────────────────────────────────────────────────────────────
174
+
175
+ /** Start autonomous random blinking and looking timers. */
176
+ _startRandomBehavior() {
177
+ // Random blinking: every 2-6 seconds
178
+ const scheduleBlink = () => {
179
+ this._blinkTimer = setTimeout(() => {
180
+ this.blink();
181
+ scheduleBlink();
182
+ }, 2000 + Math.random() * 4000);
183
+ };
184
+ scheduleBlink();
185
+
186
+ // Mouse tracking
187
+ document.addEventListener('mousemove', this._onMouseMove);
188
+
189
+ // Random looking when mouse is idle
190
+ const scheduleRandomLook = () => {
191
+ this._lookTimer = setTimeout(() => {
192
+ if (Date.now() - this._lastMouseMove > 2000) {
193
+ const x = window.innerWidth * (0.2 + Math.random() * 0.6);
194
+ const y = window.innerHeight * (0.15 + Math.random() * 0.5);
195
+ this.updateEyePosition(x, y);
196
+ }
197
+ scheduleRandomLook();
198
+ }, 1500 + Math.random() * 2500);
199
+ };
200
+ scheduleRandomLook();
201
+ }
202
+
203
+ /** @private */
204
+ _handleMouseMove(e) {
205
+ this._lastMouseMove = Date.now();
206
+ this.updateEyePosition(e.clientX, e.clientY);
207
+ }
208
+ }