agentvibes-avatars 0.1.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/LICENSE +34 -0
- package/README.md +77 -0
- package/avatars.json +35 -0
- package/bin/cli.js +109 -0
- package/package.json +40 -0
- package/public/cosmic.html +539 -0
- package/public/gallery.html +77 -0
- package/public/index.html +484 -0
- package/server.js +286 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>AgentVibes TalkingHead</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
8
|
+
|
|
9
|
+
body {
|
|
10
|
+
background: transparent;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
width: 100vw;
|
|
13
|
+
height: 100vh;
|
|
14
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Avatar stage — fills the window so TalkingHead's camera centers the head */
|
|
18
|
+
#stage {
|
|
19
|
+
position: fixed;
|
|
20
|
+
inset: 0;
|
|
21
|
+
width: 100vw;
|
|
22
|
+
height: 100vh;
|
|
23
|
+
z-index: 10;
|
|
24
|
+
}
|
|
25
|
+
#stage.visible { /* always-on; class kept for compatibility */ }
|
|
26
|
+
|
|
27
|
+
/* 3D canvas fills the stage */
|
|
28
|
+
#avatar-root {
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 100%;
|
|
31
|
+
position: relative;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Info badge above avatar head */
|
|
35
|
+
#badge {
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: 60px;
|
|
38
|
+
left: 50%;
|
|
39
|
+
transform: translateX(-50%);
|
|
40
|
+
display: none;
|
|
41
|
+
background: rgba(10, 10, 20, 0.82);
|
|
42
|
+
backdrop-filter: blur(6px);
|
|
43
|
+
color: #e8e8ff;
|
|
44
|
+
padding: 7px 16px;
|
|
45
|
+
border-radius: 999px;
|
|
46
|
+
font-size: 13px;
|
|
47
|
+
font-weight: 500;
|
|
48
|
+
letter-spacing: 0.3px;
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
51
|
+
box-shadow: 0 2px 16px rgba(0,0,0,0.4);
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
}
|
|
54
|
+
#badge.visible { display: block; }
|
|
55
|
+
|
|
56
|
+
/* Status indicator (top-right, small) */
|
|
57
|
+
#status {
|
|
58
|
+
position: fixed;
|
|
59
|
+
top: 12px;
|
|
60
|
+
right: 14px;
|
|
61
|
+
font-size: 11px;
|
|
62
|
+
color: rgba(255,255,255,0.35);
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Click-to-enable audio hint */
|
|
67
|
+
#enable-audio {
|
|
68
|
+
position: fixed;
|
|
69
|
+
top: 50%;
|
|
70
|
+
left: 50%;
|
|
71
|
+
transform: translate(-50%, -50%);
|
|
72
|
+
display: none;
|
|
73
|
+
background: rgba(20, 120, 60, 0.92);
|
|
74
|
+
color: #fff;
|
|
75
|
+
padding: 14px 22px;
|
|
76
|
+
border-radius: 999px;
|
|
77
|
+
font-size: 15px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
z-index: 50;
|
|
81
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Live caption — text of the current clip */
|
|
85
|
+
#caption {
|
|
86
|
+
position: fixed;
|
|
87
|
+
left: 50%;
|
|
88
|
+
transform: translateX(-50%);
|
|
89
|
+
bottom: 18px;
|
|
90
|
+
max-width: 92vw;
|
|
91
|
+
text-align: center;
|
|
92
|
+
font-size: 18px;
|
|
93
|
+
line-height: 1.4;
|
|
94
|
+
color: #fff;
|
|
95
|
+
background: rgba(10,10,20,0.72);
|
|
96
|
+
backdrop-filter: blur(6px);
|
|
97
|
+
padding: 12px 18px;
|
|
98
|
+
border-radius: 14px;
|
|
99
|
+
display: none;
|
|
100
|
+
border: 1px solid rgba(255,255,255,0.12);
|
|
101
|
+
z-index: 30;
|
|
102
|
+
}
|
|
103
|
+
#caption.visible { display: block; }
|
|
104
|
+
#caption .spk { font-size: 12px; opacity: 0.6; display: block; margin-bottom: 4px; }
|
|
105
|
+
|
|
106
|
+
/* Clip history — clickable chips to replay */
|
|
107
|
+
#clips {
|
|
108
|
+
position: fixed;
|
|
109
|
+
top: 10px;
|
|
110
|
+
left: 10px;
|
|
111
|
+
width: 300px;
|
|
112
|
+
max-height: 64vh;
|
|
113
|
+
overflow-y: auto;
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
gap: 6px;
|
|
117
|
+
z-index: 20;
|
|
118
|
+
}
|
|
119
|
+
.clip {
|
|
120
|
+
background: rgba(20,20,35,0.82);
|
|
121
|
+
color: #dfe3ff;
|
|
122
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
123
|
+
border-radius: 10px;
|
|
124
|
+
padding: 8px 10px;
|
|
125
|
+
font-size: 12px;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
transition: background 0.15s, border-color 0.15s;
|
|
128
|
+
}
|
|
129
|
+
.clip:hover { background: rgba(45,45,75,0.92); }
|
|
130
|
+
.clip .meta { font-size: 10px; opacity: 0.55; margin-bottom: 3px; }
|
|
131
|
+
.clip.playing { border-color: #4ade80; background: rgba(30,70,45,0.9); }
|
|
132
|
+
|
|
133
|
+
/* Debug panel — bottom-right, shows decode/lip-sync ground truth */
|
|
134
|
+
#debug {
|
|
135
|
+
position: fixed;
|
|
136
|
+
right: 8px;
|
|
137
|
+
bottom: 8px;
|
|
138
|
+
width: 340px;
|
|
139
|
+
max-height: 40vh;
|
|
140
|
+
overflow-y: auto;
|
|
141
|
+
background: rgba(0,0,0,0.72);
|
|
142
|
+
color: #9effa0;
|
|
143
|
+
font-family: ui-monospace, Consolas, monospace;
|
|
144
|
+
font-size: 10px;
|
|
145
|
+
line-height: 1.45;
|
|
146
|
+
padding: 8px 10px;
|
|
147
|
+
border-radius: 8px;
|
|
148
|
+
white-space: pre-wrap;
|
|
149
|
+
z-index: 40;
|
|
150
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Error overlay */
|
|
154
|
+
#error-msg {
|
|
155
|
+
position: fixed;
|
|
156
|
+
top: 50%;
|
|
157
|
+
left: 50%;
|
|
158
|
+
transform: translate(-50%, -50%);
|
|
159
|
+
background: rgba(180,40,40,0.85);
|
|
160
|
+
color: #fff;
|
|
161
|
+
padding: 16px 24px;
|
|
162
|
+
border-radius: 10px;
|
|
163
|
+
font-size: 13px;
|
|
164
|
+
max-width: 400px;
|
|
165
|
+
text-align: center;
|
|
166
|
+
display: none;
|
|
167
|
+
}
|
|
168
|
+
</style>
|
|
169
|
+
</head>
|
|
170
|
+
<body>
|
|
171
|
+
|
|
172
|
+
<div id="stage">
|
|
173
|
+
<div id="avatar-root"></div>
|
|
174
|
+
<div id="badge"></div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div id="clips"></div>
|
|
178
|
+
<div id="caption"></div>
|
|
179
|
+
|
|
180
|
+
<div id="status">connecting…</div>
|
|
181
|
+
<div id="enable-audio">🔊 Click to enable audio</div>
|
|
182
|
+
<div id="debug"></div>
|
|
183
|
+
<div id="error-msg"></div>
|
|
184
|
+
|
|
185
|
+
<!-- Import map: resolves the bare "three" specifiers that talkinghead.mjs imports.
|
|
186
|
+
Must precede the module script below or the top-level import throws. -->
|
|
187
|
+
<script type="importmap">
|
|
188
|
+
{ "imports":
|
|
189
|
+
{
|
|
190
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js/+esm",
|
|
191
|
+
"three/examples/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/",
|
|
192
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<script type="module">
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Imports — TalkingHead.js from CDN (GitHub via jsDelivr)
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
import { TalkingHead } from 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.3/modules/talkinghead.mjs';
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// State
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
const stage = document.getElementById('stage');
|
|
207
|
+
const badge = document.getElementById('badge');
|
|
208
|
+
const status = document.getElementById('status');
|
|
209
|
+
const errBox = document.getElementById('error-msg');
|
|
210
|
+
const root = document.getElementById('avatar-root');
|
|
211
|
+
const caption = document.getElementById('caption');
|
|
212
|
+
const clipsBox = document.getElementById('clips');
|
|
213
|
+
let clipSeq = 0;
|
|
214
|
+
|
|
215
|
+
function escapeHtml(s) {
|
|
216
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const debugBox = document.getElementById('debug');
|
|
220
|
+
function dbg(msg) {
|
|
221
|
+
if (!debugBox) return;
|
|
222
|
+
const t = new Date().toLocaleTimeString();
|
|
223
|
+
debugBox.textContent = `${t} ${msg}\n` + debugBox.textContent;
|
|
224
|
+
if (debugBox.textContent.length > 4000) debugBox.textContent = debugBox.textContent.slice(0, 4000);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let head = null; // TalkingHead instance
|
|
228
|
+
let currentKey = null; // e.g. "female-1"
|
|
229
|
+
let avatarCfg = null; // avatars.json content
|
|
230
|
+
let isSpeaking = false;
|
|
231
|
+
let hideTimer = null;
|
|
232
|
+
// voice → avatar key (persisted in sessionStorage so it survives reconnects)
|
|
233
|
+
const voiceMap = JSON.parse(sessionStorage.getItem('voiceMap') || '{}');
|
|
234
|
+
|
|
235
|
+
function showError(msg) {
|
|
236
|
+
errBox.textContent = msg;
|
|
237
|
+
errBox.style.display = 'block';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function setStatus(msg) {
|
|
241
|
+
status.textContent = msg;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Load avatar config
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
async function loadAvatarCfg() {
|
|
248
|
+
const r = await fetch('/avatars.json');
|
|
249
|
+
avatarCfg = await r.json();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Gender detection from voice name
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function detectGender(voice) {
|
|
256
|
+
if (!voice) return 'neutral';
|
|
257
|
+
// Kokoro pattern: 2nd char of 2-letter prefix = gender (f/m)
|
|
258
|
+
if (/^[a-z]{2}_[a-z0-9_]+$/.test(voice)) {
|
|
259
|
+
const g = voice[1];
|
|
260
|
+
if (g === 'f') return 'female';
|
|
261
|
+
if (g === 'm') return 'male';
|
|
262
|
+
}
|
|
263
|
+
const vl = voice.toLowerCase();
|
|
264
|
+
if (/\b(amy|anna|bella|emma|ljspeech|chloe|diana|ella|eva|nova|river|heart|star|sky|luna|rose|lily|dawn|sage|ivy|june|fable|shimmer|female|woman|girl|lady)\b/.test(vl)) return 'female';
|
|
265
|
+
if (/\b(ryan|alex|leo|joe|eric|michael|adam|brian|mark|robert|david|james|john|male|man|guy|onyx|will|echo)\b/.test(vl)) return 'male';
|
|
266
|
+
return 'neutral';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Assign and remember an avatar key for a voice
|
|
270
|
+
function avatarKeyForVoice(voice) {
|
|
271
|
+
if (voiceMap[voice]) return voiceMap[voice];
|
|
272
|
+
const gender = detectGender(voice);
|
|
273
|
+
const order = avatarCfg.genderOrder[gender] || avatarCfg.genderOrder.neutral;
|
|
274
|
+
// Pick the first one not already used by another voice
|
|
275
|
+
const used = new Set(Object.values(voiceMap));
|
|
276
|
+
const key = order.find(k => !used.has(k)) || order[0];
|
|
277
|
+
voiceMap[voice] = key;
|
|
278
|
+
sessionStorage.setItem('voiceMap', JSON.stringify(voiceMap));
|
|
279
|
+
return key;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// TalkingHead setup
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
async function ensureHead() {
|
|
286
|
+
if (head) return;
|
|
287
|
+
head = new TalkingHead(root, {
|
|
288
|
+
// TalkingHead's constructor REQUIRES a truthy ttsEndpoint, but we never use
|
|
289
|
+
// its built-in Google TTS — we feed our own audio via speakAudio(). This
|
|
290
|
+
// placeholder satisfies the check and is never actually called.
|
|
291
|
+
ttsEndpoint: 'https://localhost/_unused_tts',
|
|
292
|
+
cameraView: 'upper',
|
|
293
|
+
cameraRotateEnable: false,
|
|
294
|
+
avatarMood: 'neutral',
|
|
295
|
+
avatarIdleEyeContact: 0.5,
|
|
296
|
+
avatarIdleHeadMove: 0.5,
|
|
297
|
+
// Boost speech loudness — kokoro/piper output was very quiet through the
|
|
298
|
+
// default Web Audio gain. (null default ≈ 1.0; bump to make it audible.)
|
|
299
|
+
mixerGainSpeech: 3.0,
|
|
300
|
+
});
|
|
301
|
+
// Belt-and-suspenders: also set the live gain node in case opt is ignored.
|
|
302
|
+
try { if (head.audioSpeechGainNode) head.audioSpeechGainNode.gain.value = 3.0; } catch {}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function loadAvatarIfNeeded(avatarKey) {
|
|
306
|
+
if (currentKey === avatarKey) return;
|
|
307
|
+
const cfg = avatarCfg.avatars[avatarKey];
|
|
308
|
+
if (!cfg) { console.warn('Unknown avatar key:', avatarKey); return; }
|
|
309
|
+
|
|
310
|
+
setStatus(`loading avatar "${cfg.label}"…`);
|
|
311
|
+
try {
|
|
312
|
+
await head.showAvatar({ url: cfg.url, body: cfg.body, avatarMood: 'neutral' });
|
|
313
|
+
currentKey = avatarKey;
|
|
314
|
+
setStatus('ready');
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error('Avatar load failed:', e);
|
|
317
|
+
showError(`Avatar failed to load.\nCheck avatars.json — the RPM avatar IDs may need updating.\n\n${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Handle a speak event from the server
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Add a clickable clip chip to the history; clicking replays it.
|
|
325
|
+
function addClipChip(clip) {
|
|
326
|
+
const el = document.createElement('div');
|
|
327
|
+
el.className = 'clip';
|
|
328
|
+
el.innerHTML = `<div class="meta">▶ ${escapeHtml(clip.voice || '?')} · ${escapeHtml(clip.project || clip.origin || '')}</div>` +
|
|
329
|
+
`${escapeHtml((clip.text || '(no text)').slice(0, 180))}`;
|
|
330
|
+
el.addEventListener('click', () => playClip(clip, el));
|
|
331
|
+
clipsBox.prepend(el);
|
|
332
|
+
while (clipsBox.children.length > 25) clipsBox.removeChild(clipsBox.lastChild);
|
|
333
|
+
return el;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Fetch + decode + speak a clip (used for both first play and replay-on-click).
|
|
337
|
+
// Re-fetches the audio URL each time (the server keeps each file ~120s).
|
|
338
|
+
async function playClip(clip, chipEl) {
|
|
339
|
+
// Caption: show the spoken text
|
|
340
|
+
caption.innerHTML = `<span class="spk">${escapeHtml(clip.voice || '')} · ${escapeHtml(clip.project || clip.origin || '')}</span>` +
|
|
341
|
+
`${escapeHtml(clip.text || '(no text provided)')}`;
|
|
342
|
+
caption.classList.add('visible');
|
|
343
|
+
|
|
344
|
+
document.querySelectorAll('.clip.playing').forEach(e => e.classList.remove('playing'));
|
|
345
|
+
if (chipEl) chipEl.classList.add('playing');
|
|
346
|
+
|
|
347
|
+
isSpeaking = true;
|
|
348
|
+
try {
|
|
349
|
+
dbg(`speak: text="${(clip.text||'').slice(0,40)}" (${(clip.text||'').length} chars) voice=${clip.voice}`);
|
|
350
|
+
if (head.audioCtx && head.audioCtx.state !== 'running') {
|
|
351
|
+
await head.audioCtx.resume();
|
|
352
|
+
}
|
|
353
|
+
dbg(`ctx=${head.audioCtx.state} gain=${head.audioSpeechGainNode ? head.audioSpeechGainNode.gain.value : '?'}`);
|
|
354
|
+
const arrbuf = await (await fetch(clip.audioUrl)).arrayBuffer();
|
|
355
|
+
const audioBuf = await head.audioCtx.decodeAudioData(arrbuf);
|
|
356
|
+
dbg(`decoded OK: ${audioBuf.duration.toFixed(2)}s ${audioBuf.sampleRate}Hz ${audioBuf.numberOfChannels}ch`);
|
|
357
|
+
|
|
358
|
+
const r = { audio: audioBuf };
|
|
359
|
+
const text = (clip.text || '').trim();
|
|
360
|
+
if (text) {
|
|
361
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
362
|
+
const per = (audioBuf.duration * 1000) / Math.max(1, words.length);
|
|
363
|
+
r.words = words;
|
|
364
|
+
r.wtimes = words.map((_, i) => Math.round(i * per));
|
|
365
|
+
r.wdurations = words.map(() => Math.round(per * 0.9));
|
|
366
|
+
dbg(`lipsync: ${words.length} words → speakAudio (lang=en)`);
|
|
367
|
+
} else {
|
|
368
|
+
dbg(`lipsync: NO TEXT — mouth will not move`);
|
|
369
|
+
}
|
|
370
|
+
setStatus(`speaking · ${r.words ? r.words.length : 0}w · ${audioBuf.duration.toFixed(1)}s · ctx=${head.audioCtx.state}`);
|
|
371
|
+
head.speakAudio(r, { lipsyncLang: 'en' });
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error('playClip failed:', e);
|
|
374
|
+
dbg(`ERROR: ${e.message} — falling back to <audio>`);
|
|
375
|
+
setStatus('audio error: ' + e.message);
|
|
376
|
+
caption.innerHTML += `<br><small style="opacity:.6">⚠ ${escapeHtml(e.message)}</small>`;
|
|
377
|
+
try { const a = new Audio(clip.audioUrl); a.volume = 1; await a.play(); } catch (e2) {}
|
|
378
|
+
}
|
|
379
|
+
isSpeaking = false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function handleSpeak(msg) {
|
|
383
|
+
// Abort any pending hide
|
|
384
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
385
|
+
|
|
386
|
+
// Show stage
|
|
387
|
+
stage.classList.add('visible');
|
|
388
|
+
|
|
389
|
+
// Update badge
|
|
390
|
+
const proj = msg.project || msg.llm || 'AgentVibes';
|
|
391
|
+
const origin = msg.origin || 'remote';
|
|
392
|
+
badge.textContent = `📁 ${proj} • from ${origin}`;
|
|
393
|
+
badge.classList.add('visible');
|
|
394
|
+
|
|
395
|
+
// Ensure TalkingHead + correct avatar
|
|
396
|
+
await ensureHead();
|
|
397
|
+
const voice = msg.voice || '';
|
|
398
|
+
await loadAvatarIfNeeded(avatarKeyForVoice(voice));
|
|
399
|
+
|
|
400
|
+
if (!msg.audioUrl) return;
|
|
401
|
+
|
|
402
|
+
// Record the clip (text shown + clickable to replay), then play it.
|
|
403
|
+
const clip = { id: ++clipSeq, audioUrl: msg.audioUrl, text: msg.text || '', voice, project: proj, origin };
|
|
404
|
+
const chip = addClipChip(clip);
|
|
405
|
+
await playClip(clip, chip);
|
|
406
|
+
|
|
407
|
+
// Keep the talking head on screen at all times; only fade the badge out.
|
|
408
|
+
hideTimer = setTimeout(() => {
|
|
409
|
+
badge.classList.remove('visible');
|
|
410
|
+
hideTimer = null;
|
|
411
|
+
}, 1500);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// SSE connection (auto-reconnects on disconnect)
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
function connectSSE() {
|
|
418
|
+
setStatus('connecting…');
|
|
419
|
+
const es = new EventSource('/events');
|
|
420
|
+
|
|
421
|
+
es.onmessage = async (event) => {
|
|
422
|
+
let msg;
|
|
423
|
+
try { msg = JSON.parse(event.data); } catch { return; }
|
|
424
|
+
|
|
425
|
+
if (msg.type === 'connected') {
|
|
426
|
+
setStatus('connected');
|
|
427
|
+
console.log('[SSE] Connected, id=', msg.id);
|
|
428
|
+
} else if (msg.type === 'speak') {
|
|
429
|
+
handleSpeak(msg).catch(e => console.error('[speak]', e));
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
es.onerror = () => {
|
|
434
|
+
setStatus('reconnecting…');
|
|
435
|
+
es.close();
|
|
436
|
+
setTimeout(connectSSE, 3000);
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Audio unlock — browsers start the Web Audio context suspended until a user
|
|
442
|
+
// gesture. Resume on the first click/key anywhere; show a hint until running.
|
|
443
|
+
// (The dedicated avatar window launches Chrome with
|
|
444
|
+
// --autoplay-policy=no-user-gesture-required so this usually self-clears.)
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
function setupAudioUnlock() {
|
|
447
|
+
const hint = document.getElementById('enable-audio');
|
|
448
|
+
const ctxRunning = () => head && head.audioCtx && head.audioCtx.state === 'running';
|
|
449
|
+
const tryResume = async () => {
|
|
450
|
+
try { if (head && head.audioCtx && head.audioCtx.state !== 'running') await head.audioCtx.resume(); } catch {}
|
|
451
|
+
if (ctxRunning() && hint) hint.style.display = 'none';
|
|
452
|
+
};
|
|
453
|
+
window.addEventListener('click', tryResume);
|
|
454
|
+
window.addEventListener('keydown', tryResume);
|
|
455
|
+
if (hint) {
|
|
456
|
+
hint.addEventListener('click', tryResume);
|
|
457
|
+
hint.style.display = 'block';
|
|
458
|
+
}
|
|
459
|
+
// Poll briefly: if autoplay is already allowed, hide the hint automatically.
|
|
460
|
+
const poll = setInterval(() => { if (ctxRunning() && hint) { hint.style.display = 'none'; clearInterval(poll); } }, 800);
|
|
461
|
+
setTimeout(() => clearInterval(poll), 15000);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Boot
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
(async () => {
|
|
468
|
+
try {
|
|
469
|
+
await loadAvatarCfg();
|
|
470
|
+
// Always-loaded: build the head and load a default avatar up front, and
|
|
471
|
+
// keep the stage visible so the talking head is always on screen.
|
|
472
|
+
await ensureHead();
|
|
473
|
+
const defaultKey = (avatarCfg.genderOrder.neutral && avatarCfg.genderOrder.neutral[0]) || 'neutral';
|
|
474
|
+
await loadAvatarIfNeeded(defaultKey);
|
|
475
|
+
stage.classList.add('visible');
|
|
476
|
+
setupAudioUnlock();
|
|
477
|
+
connectSSE();
|
|
478
|
+
} catch (e) {
|
|
479
|
+
showError('Failed to load configuration: ' + e.message);
|
|
480
|
+
}
|
|
481
|
+
})();
|
|
482
|
+
</script>
|
|
483
|
+
</body>
|
|
484
|
+
</html>
|