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,173 @@
1
+ /**
2
+ * Config — frontend config manager (ADR-009: simple manager pattern)
3
+ *
4
+ * Loads config from the server endpoint /api/config, merges with
5
+ * compile-time defaults, and exposes a simple get/set interface.
6
+ *
7
+ * Usage:
8
+ * import { config } from './Config.js';
9
+ *
10
+ * await config.load(); // fetch from server
11
+ *
12
+ * config.get('tts.provider'); // → 'supertonic'
13
+ * config.get('missing', 'default'); // → 'default'
14
+ * config.set('ui.volume', 0.8); // local override (not persisted)
15
+ *
16
+ * config.onChange('tts.provider', (val) => { ... });
17
+ */
18
+
19
+ import { eventBus } from './EventBus.js';
20
+
21
+ /** Compile-time defaults — must not contain secrets */
22
+ const DEFAULTS = {
23
+ tts: {
24
+ provider: 'groq',
25
+ volume: 1.0,
26
+ rate: 1.0,
27
+ },
28
+ stt: {
29
+ provider: 'webspeech',
30
+ language: 'en-US',
31
+ continuous: true,
32
+ interimResults: true,
33
+ },
34
+ ui: {
35
+ theme: 'dark',
36
+ wakeWord: true,
37
+ showTranscript: true,
38
+ showFace: true,
39
+ },
40
+ music: {
41
+ enabled: true,
42
+ duckVolume: 0.15,
43
+ duckDuration: 500,
44
+ },
45
+ session: {
46
+ key: 'voice-main',
47
+ },
48
+ api: {
49
+ baseUrl: '', // same origin
50
+ configEndpoint: '/api/config',
51
+ },
52
+ };
53
+
54
+ class Config {
55
+ constructor() {
56
+ /** @type {Record<string, any>} flat key → value store */
57
+ this._store = {};
58
+ this._loaded = false;
59
+
60
+ // Seed with defaults
61
+ this._flattenInto(DEFAULTS, '');
62
+ }
63
+
64
+ /**
65
+ * Load config from the server, merging over defaults.
66
+ * Safe to call multiple times — subsequent calls re-fetch.
67
+ * @returns {Promise<void>}
68
+ */
69
+ async load() {
70
+ const endpoint = this.get('api.configEndpoint', '/api/config');
71
+ try {
72
+ const res = await fetch(endpoint);
73
+ if (res.ok) {
74
+ const data = await res.json();
75
+ this._flattenInto(data, '');
76
+ eventBus.emit('config:loaded', { source: 'server' });
77
+ } else {
78
+ console.warn(`[Config] Server returned ${res.status} — using defaults`);
79
+ eventBus.emit('config:loaded', { source: 'defaults' });
80
+ }
81
+ } catch (err) {
82
+ console.warn('[Config] Failed to fetch config — using defaults:', err.message);
83
+ eventBus.emit('config:loaded', { source: 'defaults' });
84
+ }
85
+ this._loaded = true;
86
+ }
87
+
88
+ /**
89
+ * Get a config value by dot-notation key.
90
+ * @param {string} key e.g. 'tts.provider'
91
+ * @param {*} [fallback]
92
+ * @returns {*}
93
+ */
94
+ get(key, fallback = undefined) {
95
+ return key in this._store ? this._store[key] : fallback;
96
+ }
97
+
98
+ /**
99
+ * Set a config value locally (does not persist to server).
100
+ * Emits 'config:change' event.
101
+ * @param {string} key
102
+ * @param {*} value
103
+ */
104
+ set(key, value) {
105
+ const prev = this._store[key];
106
+ this._store[key] = value;
107
+ if (prev !== value) {
108
+ eventBus.emit('config:change', { key, value, prev });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Subscribe to changes for a specific key.
114
+ * @param {string} key
115
+ * @param {Function} handler called with (newValue, oldValue)
116
+ * @returns {Function} unsubscribe
117
+ */
118
+ onChange(key, handler) {
119
+ return eventBus.on('config:change', ({ key: k, value, prev }) => {
120
+ if (k === key) handler(value, prev);
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Return all config as a nested object (reconstructed from flat store).
126
+ * @returns {Record<string, any>}
127
+ */
128
+ all() {
129
+ const result = {};
130
+ for (const [key, value] of Object.entries(this._store)) {
131
+ this._setNested(result, key.split('.'), value);
132
+ }
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Whether config has been loaded from server at least once.
138
+ * @returns {boolean}
139
+ */
140
+ get isLoaded() {
141
+ return this._loaded;
142
+ }
143
+
144
+ // ── private ──────────────────────────────────────────────────────────────
145
+
146
+ /** Recursively flatten an object into dot-notation keys in this._store */
147
+ _flattenInto(obj, prefix) {
148
+ for (const [k, v] of Object.entries(obj)) {
149
+ const fullKey = prefix ? `${prefix}.${k}` : k;
150
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
151
+ this._flattenInto(v, fullKey);
152
+ } else {
153
+ this._store[fullKey] = v;
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Set a nested key path on an object */
159
+ _setNested(obj, parts, value) {
160
+ const key = parts[0];
161
+ if (parts.length === 1) {
162
+ obj[key] = value;
163
+ } else {
164
+ if (!obj[key] || typeof obj[key] !== 'object') obj[key] = {};
165
+ this._setNested(obj[key], parts.slice(1), value);
166
+ }
167
+ }
168
+ }
169
+
170
+ // Singleton
171
+ export const config = new Config();
172
+
173
+ export { Config };
@@ -0,0 +1,307 @@
1
+ /**
2
+ * EmotionEngine — infers emotional state from LLM responses and drives face mood.
3
+ *
4
+ * Connects LLM response content to the FaceManager's mood system (P3-T8).
5
+ *
6
+ * Two signal sources (in priority order):
7
+ * 1. Explicit emotion_state from server (emitted as 'session:emotion' event)
8
+ * 2. Text-based keyword inference from LLM response text
9
+ *
10
+ * Usage:
11
+ * import { emotionEngine } from './EmotionEngine.js';
12
+ * emotionEngine.start(); // call once at app boot
13
+ * emotionEngine.stop(); // call on teardown
14
+ *
15
+ * Events consumed from eventBus:
16
+ * 'session:message' { role: 'assistant', text: string } — infer mood from text
17
+ * 'session:emotion' { mood, intensity, confidence } — explicit override
18
+ * 'session:thinking' {} — face to 'thinking'
19
+ * 'tts:stop' {} — reset to neutral after speech
20
+ *
21
+ * Events emitted on eventBus:
22
+ * 'emotion:change' { mood, intensity, source } — mood was updated
23
+ *
24
+ * ADR-004: emotion_state schema — mood + intensity + confidence
25
+ * ADR-009: simple manager pattern (no framework)
26
+ */
27
+
28
+ import { eventBus } from './EventBus.js';
29
+ import { faceManager, VALID_MOODS } from '../face/BaseFace.js';
30
+
31
+ // ── Keyword tables ─────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Mood inference rules.
35
+ * Each entry has a mood and an array of regex patterns.
36
+ * Rules are tested in order; first match wins.
37
+ * @type {Array<{mood: string, patterns: RegExp[]}>}
38
+ */
39
+ const MOOD_RULES = [
40
+ {
41
+ mood: 'surprised',
42
+ patterns: [
43
+ /\bwh?oa+\b/i,
44
+ /\bwow\b/i,
45
+ /\bno way\b/i,
46
+ /\bholly?\s*sh+it\b/i,
47
+ /\bwhat the\b/i,
48
+ /\bseriously\?/i,
49
+ /\breally\?\b/i,
50
+ /\boh\s+sh+it\b/i,
51
+ /\bunbelievable\b/i,
52
+ /\bi can't believe\b/i
53
+ ]
54
+ },
55
+ {
56
+ mood: 'happy',
57
+ patterns: [
58
+ /\bha(ha)+\b/i,
59
+ /\blol\b/i,
60
+ /\blmao\b/i,
61
+ /\bexcellent\b/i,
62
+ /\bawesome\b/i,
63
+ /\bfantastic\b/i,
64
+ /\bperfect\b/i,
65
+ /\bcongrat(ulation)?s?\b/i,
66
+ /\bbrilliant\b/i,
67
+ /\blove it\b/i,
68
+ /\bthat's great\b/i,
69
+ /\bnice\b/i,
70
+ /\bsweet\b/i,
71
+ /\bwoo+\b/i,
72
+ /\blet'?s go\b/i
73
+ ]
74
+ },
75
+ {
76
+ mood: 'thinking',
77
+ patterns: [
78
+ /\bhmm+\b/i,
79
+ /\blet me think\b/i,
80
+ /\binteresting\b/i,
81
+ /\bcalculat/i,
82
+ /\banalyz/i,
83
+ /\bprocessing\b/i,
84
+ /\bconsidering\b/i,
85
+ /\bactually\b/i,
86
+ /\bwell,?\s+technically\b/i,
87
+ /\bto be (fair|honest)\b/i
88
+ ]
89
+ },
90
+ {
91
+ mood: 'sad',
92
+ patterns: [
93
+ /\bsorry\b/i,
94
+ /\bunfortunately\b/i,
95
+ /\bi (can't|cannot|couldn't)\b/i,
96
+ /\bfailed\b/i,
97
+ /\berror\b/i,
98
+ /\bmy bad\b/i,
99
+ /\bapolog/i,
100
+ /\bdisappoint/i,
101
+ /\bregret\b/i
102
+ ]
103
+ },
104
+ {
105
+ mood: 'angry',
106
+ patterns: [
107
+ /\bannoying\b/i,
108
+ /\bfrustrat/i,
109
+ /\bstupid\b/i,
110
+ /\bidiot/i,
111
+ /\bwhy would you\b/i,
112
+ /\bthat's wrong\b/i,
113
+ /\bfor f+uck'?s sake\b/i,
114
+ /\bfor (the love|crying out loud)\b/i
115
+ ]
116
+ }
117
+ ];
118
+
119
+ // Minimum confidence to override neutral (0–1)
120
+ const INFERENCE_THRESHOLD = 0.4;
121
+
122
+ // ── EmotionEngine class ────────────────────────────────────────────────────
123
+
124
+ class EmotionEngine {
125
+ constructor() {
126
+ /** @type {string} */
127
+ this._currentMood = 'neutral';
128
+ /** @type {number} */
129
+ this._currentIntensity = 0.5;
130
+ /** @type {boolean} */
131
+ this._active = false;
132
+ /** @type {Function[]} unsubscribe functions */
133
+ this._unsubs = [];
134
+ /** @type {ReturnType<typeof setTimeout>|null} */
135
+ this._resetTimer = null;
136
+ }
137
+
138
+ // ── Lifecycle ────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Wire up EventBus subscriptions. Call once at app boot.
142
+ */
143
+ start() {
144
+ if (this._active) return;
145
+ this._active = true;
146
+
147
+ this._unsubs = [
148
+ // Explicit server-provided emotion (highest priority)
149
+ eventBus.on('session:emotion', (data) => this._onExplicitEmotion(data)),
150
+
151
+ // Text-based inference on completed assistant message
152
+ eventBus.on('session:message', (data) => {
153
+ if (data?.role === 'assistant') {
154
+ this._inferFromText(data.text);
155
+ }
156
+ }),
157
+
158
+ // When TTS finishes speaking, return to neutral after a short delay
159
+ eventBus.on('tts:stop', () => this._scheduleReset()),
160
+ ];
161
+
162
+ console.log('[EmotionEngine] Started');
163
+ }
164
+
165
+ /**
166
+ * Remove all EventBus subscriptions.
167
+ */
168
+ stop() {
169
+ if (!this._active) return;
170
+ this._active = false;
171
+
172
+ this._unsubs.forEach(unsub => unsub());
173
+ this._unsubs = [];
174
+
175
+ if (this._resetTimer) {
176
+ clearTimeout(this._resetTimer);
177
+ this._resetTimer = null;
178
+ }
179
+
180
+ console.log('[EmotionEngine] Stopped');
181
+ }
182
+
183
+ // ── Public API ───────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Manually set a mood (useful for testing or external overrides).
187
+ * @param {string} mood one of VALID_MOODS
188
+ * @param {number} [intensity=0.6] 0–1
189
+ */
190
+ setMood(mood, intensity = 0.6) {
191
+ this._applyMood(mood, intensity, 'manual');
192
+ }
193
+
194
+ /** @returns {string} current mood */
195
+ get currentMood() {
196
+ return this._currentMood;
197
+ }
198
+
199
+ /** @returns {number} current intensity (0–1) */
200
+ get currentIntensity() {
201
+ return this._currentIntensity;
202
+ }
203
+
204
+ // ── Private handlers ─────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Handle explicit emotion_state from server (ADR-004 schema).
208
+ * @param {{ mood?: string, intensity?: number, confidence?: number }} data
209
+ */
210
+ _onExplicitEmotion(data) {
211
+ if (!data?.mood) return;
212
+
213
+ const mood = VALID_MOODS.includes(data.mood) ? data.mood : 'neutral';
214
+ const intensity = typeof data.intensity === 'number'
215
+ ? Math.max(0, Math.min(1, data.intensity))
216
+ : 0.7;
217
+
218
+ this._applyMood(mood, intensity, 'server');
219
+ }
220
+
221
+ /**
222
+ * Infer a mood from the LLM's response text using keyword rules.
223
+ * Falls back to 'neutral' if no rule matches above threshold.
224
+ * @param {string} text
225
+ */
226
+ _inferFromText(text) {
227
+ if (!text || typeof text !== 'string') return;
228
+
229
+ // Cancel any pending reset — we're about to speak
230
+ if (this._resetTimer) {
231
+ clearTimeout(this._resetTimer);
232
+ this._resetTimer = null;
233
+ }
234
+
235
+ const result = this._classify(text);
236
+
237
+ if (result.confidence >= INFERENCE_THRESHOLD) {
238
+ this._applyMood(result.mood, result.intensity, 'inference');
239
+ }
240
+ // If no signal, leave face as-is (VoiceSession already set 'neutral' on first delta)
241
+ }
242
+
243
+ /**
244
+ * Classify text against MOOD_RULES.
245
+ * @param {string} text
246
+ * @returns {{ mood: string, intensity: number, confidence: number }}
247
+ */
248
+ _classify(text) {
249
+ let bestMood = 'neutral';
250
+ let bestScore = 0;
251
+
252
+ for (const rule of MOOD_RULES) {
253
+ let matchCount = 0;
254
+ for (const pattern of rule.patterns) {
255
+ if (pattern.test(text)) {
256
+ matchCount++;
257
+ }
258
+ }
259
+ if (matchCount > 0) {
260
+ // Score = matchCount / total patterns (normalised 0–1)
261
+ const score = matchCount / rule.patterns.length;
262
+ if (score > bestScore) {
263
+ bestScore = score;
264
+ bestMood = rule.mood;
265
+ }
266
+ }
267
+ }
268
+
269
+ // Intensity scales with how many signals fired
270
+ const intensity = 0.4 + bestScore * 0.6;
271
+ return { mood: bestMood, intensity, confidence: bestScore };
272
+ }
273
+
274
+ /**
275
+ * Apply a mood to the FaceManager and emit 'emotion:change'.
276
+ * @param {string} mood
277
+ * @param {number} intensity
278
+ * @param {string} source 'server' | 'inference' | 'manual'
279
+ */
280
+ _applyMood(mood, intensity, source) {
281
+ mood = VALID_MOODS.includes(mood) ? mood : 'neutral';
282
+
283
+ this._currentMood = mood;
284
+ this._currentIntensity = intensity;
285
+
286
+ faceManager.setMood(mood);
287
+
288
+ eventBus.emit('emotion:change', { mood, intensity, source });
289
+
290
+ console.log(`[EmotionEngine] Mood → ${mood} (intensity=${intensity.toFixed(2)}, src=${source})`);
291
+ }
292
+
293
+ /**
294
+ * Schedule a return to neutral after TTS ends.
295
+ * Cancelled if a new emotion comes in first.
296
+ */
297
+ _scheduleReset() {
298
+ if (this._resetTimer) clearTimeout(this._resetTimer);
299
+ this._resetTimer = setTimeout(() => {
300
+ this._resetTimer = null;
301
+ this._applyMood('neutral', 0.5, 'reset');
302
+ }, 1500);
303
+ }
304
+ }
305
+
306
+ // Singleton
307
+ export const emotionEngine = new EmotionEngine();
@@ -0,0 +1,180 @@
1
+ /**
2
+ * EventBridge — Multi-Agent Framework event bus (P6-T1)
3
+ *
4
+ * The ONLY coupling point between the app shell and agent adapters.
5
+ * Shell modules and adapters communicate exclusively through this bridge.
6
+ *
7
+ * Ref: future-dev-plans/17-MULTI-AGENT-FRAMEWORK.md
8
+ *
9
+ * Usage:
10
+ * import { bridge, AgentEvents, AgentActions } from './EventBridge.js';
11
+ *
12
+ * // Adapter emits:
13
+ * bridge.emit(AgentEvents.CONNECTED);
14
+ * bridge.emit(AgentEvents.STATE_CHANGED, { state: 'speaking' });
15
+ *
16
+ * // Shell subscribes:
17
+ * const unsub = bridge.on(AgentEvents.MOOD, ({ mood }) => FaceModule.setMood(mood));
18
+ *
19
+ * // UI triggers agent action:
20
+ * bridge.emit(AgentActions.SEND_MESSAGE, { text: 'Hello' });
21
+ */
22
+
23
+ // ─────────────────────────────────────────────
24
+ // Agent → UI Events (things the agent tells the UI)
25
+ // ─────────────────────────────────────────────
26
+
27
+ export const AgentEvents = {
28
+ // Connection lifecycle
29
+ CONNECTED: 'agent:connected', // Agent is ready
30
+ DISCONNECTED: 'agent:disconnected', // Agent disconnected
31
+ ERROR: 'agent:error', // { message, code }
32
+
33
+ // Conversation state
34
+ STATE_CHANGED: 'agent:state', // { state: 'speaking'|'listening'|'idle'|'thinking' }
35
+
36
+ // Content
37
+ MESSAGE: 'agent:message', // { role: 'user'|'assistant', text, final: bool }
38
+ TRANSCRIPT: 'agent:transcript', // { text, partial: bool } — live STT
39
+
40
+ // Audio signals (NOT the audio itself — each adapter handles its own audio)
41
+ TTS_PLAYING: 'agent:tts_playing', // TTS audio started (for mouth animation)
42
+ TTS_STOPPED: 'agent:tts_stopped', // TTS audio ended
43
+ AUDIO_LEVEL: 'agent:audio_level', // { level: 0-1 } for waveform mouth
44
+
45
+ // Capabilities
46
+ MOOD: 'agent:mood', // { mood: 'happy'|'thinking'|'sad'|'neutral'|'listening' }
47
+ CANVAS_CMD: 'agent:canvas', // { action: 'present'|'close', url }
48
+ TOOL_CALLED: 'agent:tool', // { name, params, result } for ActionConsole
49
+
50
+ // Music integration
51
+ MUSIC_PLAY: 'agent:music_play', // { track?, action: 'play'|'skip'|'stop'|'pause' }
52
+ MUSIC_SYNC: 'agent:music_sync', // Trigger syncMusicWithServer()
53
+
54
+ // Sound effects
55
+ PLAY_SOUND: 'agent:play_sound', // { sound: 'air_horn', type: 'dj'|'caller' }
56
+
57
+ // Caller effect
58
+ CALLER_EFFECT: 'agent:caller_effect', // { enabled: bool }
59
+
60
+ // Commercial
61
+ COMMERCIAL: 'agent:commercial', // { action: 'play' }
62
+ };
63
+
64
+ // ─────────────────────────────────────────────
65
+ // UI → Agent Actions (things the UI tells the agent)
66
+ // ─────────────────────────────────────────────
67
+
68
+ export const AgentActions = {
69
+ SEND_MESSAGE: 'ui:send_message', // { text }
70
+ END_SESSION: 'ui:end_session',
71
+ CONTEXT_UPDATE: 'ui:context_update', // { text } — background info injected silently
72
+ FORCE_MESSAGE: 'ui:force_message', // { text } — SYSTEM messages agent must act on
73
+ MODE_SWITCH: 'ui:mode_switch', // { mode } — switching agent mode
74
+ };
75
+
76
+ // ─────────────────────────────────────────────
77
+ // EventBridge class
78
+ // ─────────────────────────────────────────────
79
+
80
+ class EventBridge {
81
+ constructor() {
82
+ /** @type {Object.<string, Function[]>} */
83
+ this._handlers = {};
84
+ }
85
+
86
+ /**
87
+ * Subscribe to an event.
88
+ * @param {string} event
89
+ * @param {Function} handler
90
+ * @returns {Function} unsubscribe function
91
+ */
92
+ on(event, handler) {
93
+ if (!this._handlers[event]) this._handlers[event] = [];
94
+ this._handlers[event].push(handler);
95
+ // Return unsubscribe function
96
+ return () => this.off(event, handler);
97
+ }
98
+
99
+ /**
100
+ * Unsubscribe a specific handler from an event.
101
+ * @param {string} event
102
+ * @param {Function} handler
103
+ */
104
+ off(event, handler) {
105
+ if (this._handlers[event]) {
106
+ this._handlers[event] = this._handlers[event].filter(h => h !== handler);
107
+ if (this._handlers[event].length === 0) {
108
+ delete this._handlers[event];
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Subscribe to an event for one invocation only.
115
+ * @param {string} event
116
+ * @param {Function} handler
117
+ * @returns {Function} unsubscribe function
118
+ */
119
+ once(event, handler) {
120
+ const wrapper = (data) => {
121
+ handler(data);
122
+ this.off(event, wrapper);
123
+ };
124
+ return this.on(event, wrapper);
125
+ }
126
+
127
+ /**
128
+ * Emit an event to all registered handlers.
129
+ * Handler errors are caught and logged so one bad handler can't break others.
130
+ * @param {string} event
131
+ * @param {*} [data={}]
132
+ */
133
+ emit(event, data = {}) {
134
+ const handlers = this._handlers[event];
135
+ if (!handlers || handlers.length === 0) return;
136
+ // Snapshot array in case a handler modifies the list during dispatch
137
+ [...handlers].forEach(h => {
138
+ try {
139
+ h(data);
140
+ } catch (e) {
141
+ console.error(`[EventBridge] Error in "${event}" handler:`, e);
142
+ }
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Clear all handlers for a specific event.
148
+ * Used during targeted cleanup (e.g. removing canvas listeners only).
149
+ * @param {string} event
150
+ */
151
+ clearEvent(event) {
152
+ delete this._handlers[event];
153
+ }
154
+
155
+ /**
156
+ * Clear ALL handlers — nuclear option for mode switching.
157
+ * Called by AgentOrchestrator when switching adapters to guarantee
158
+ * no stale handlers from the previous adapter survive.
159
+ */
160
+ clearAll() {
161
+ this._handlers = {};
162
+ }
163
+
164
+ /**
165
+ * Return list of events that currently have listeners (for debugging).
166
+ * @returns {string[]}
167
+ */
168
+ events() {
169
+ return Object.keys(this._handlers);
170
+ }
171
+ }
172
+
173
+ // ─────────────────────────────────────────────
174
+ // Singleton — one bridge for the whole app
175
+ // ─────────────────────────────────────────────
176
+
177
+ export const bridge = new EventBridge();
178
+
179
+ // Also export the class for testing / multiple instances
180
+ export { EventBridge };