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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- 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
|
+
}
|