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,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 };
|