OppaAI commited on
Commit
6ff734a
·
1 Parent(s): f7dcf56

refactor: update LLM warmup, optimize audio handling by removing conversion, and refine VRM animation API

Browse files
Files changed (5) hide show
  1. .gitignore +5 -0
  2. app.py +170 -140
  3. core/think.py +5 -2
  4. ui/speak.py +5 -15
  5. ui/vrm.py +136 -107
.gitignore CHANGED
@@ -8,3 +8,8 @@ core/__pycache__/
8
  *.wav
9
  static/Aiko.vrm
10
  static/Aiko.vrm
 
 
 
 
 
 
8
  *.wav
9
  static/Aiko.vrm
10
  static/Aiko.vrm
11
+
12
+ .venv/
13
+ .env
14
+ .agents/
15
+ .codex/
app.py CHANGED
@@ -61,162 +61,72 @@ def build_soul_prompt(user_id: str) -> str:
61
  # HELPERS
62
  # ─────────────────────────────────────────────
63
  def _strip_for_speech(text: str) -> str:
 
64
  text = re.sub(r"\n?🔍 Searching: \*.*?\*\n?", "", text)
65
  text = re.sub(r"\n?🔧 .*?\n?", "", text)
66
  text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
67
  return text.strip()
68
 
69
 
70
- _SENTENCE_END = re.compile(r'(?<=[.!?。!?\n])\s*')
71
-
72
-
73
- def _split_ready_sentences(buffer: str):
74
- parts = _SENTENCE_END.split(buffer)
75
- if len(parts) <= 1:
76
- return [], buffer
77
- *complete, remainder = parts
78
- return [p for p in complete if p.strip()], remainder
79
-
80
-
81
  # ─────────────────────────────────────────────
82
- # STREAM CORE
83
  # ─────────────────────────────────────────────
84
- _DONE = object() # sentinel
85
 
86
-
87
- def _stream_response(message: str, history: list):
88
  """
89
- Pipeline:
90
- 1. LLM streams tokens in background thread sentence queue
91
- 2. Per sentence: TTS synthesis fires in background thread → audio queue
92
- 3. Gradio yields text+audio sentence by sentence as TTS completes
93
- 4. Chatbot shows text in sync with audio playback (sentence revealed
94
- only when its audio is ready), input unlocks after all TTS done.
95
  """
96
  history = list(history) + [
97
- {"role": "user", "content": message},
98
  {"role": "assistant", "content": "▋"},
99
  ]
 
100
  yield history, None, None
101
 
102
- # ── Stage 1: LLM sentence queue ────────────────────────────────────────
103
- sentence_q: queue.Queue = queue.Queue()
104
- llm_error = {}
105
-
106
- def _llm_thread():
107
- buffer = ""
108
- full_text = ""
109
-
110
- def _cb(token):
111
- nonlocal buffer, full_text
112
- if token.startswith("__SEARCHING__:"):
113
- q = token.split(":", 1)[1]
114
- note = f"\n🔍 Searching: *{q}*\n"
115
- buffer += note
116
- full_text += note
117
- elif token.startswith("__TOOL__:"):
118
- note = token.split(":", 1)[1]
119
- display = f"\n🔧 {note}\n"
120
- buffer += display
121
- full_text += display
122
- else:
123
- buffer += token
124
- full_text += token
125
-
126
- # Push complete sentences into queue as they arrive
127
- sentences, new_buf = _split_ready_sentences(buffer)
128
- buffer = new_buf
129
- for s in sentences:
130
- sentence_q.put(("sentence", s, full_text))
131
-
132
- try:
133
- think.chat(message, token_callback=_cb)
134
- except Exception as e:
135
- llm_error["e"] = e
136
- finally:
137
- # Flush remaining buffer
138
- if buffer.strip():
139
- sentence_q.put(("sentence", buffer.strip(), full_text))
140
- sentence_q.put(("done", full_text, full_text))
141
-
142
- threading.Thread(target=_llm_thread, daemon=True).start()
143
-
144
- # ── Stage 2: sentence → TTS queue (parallel synthesis) ───────────────────
145
- # Each sentence slot: (sentence_text, full_text_so_far, audio_queue)
146
- # Audio synthesis fires immediately when sentence is ready; we drain
147
- # in order so playback is sequential.
148
- slots: list[tuple[str, str, queue.Queue]] = []
149
- llm_done = threading.Event()
150
- final_text = [""]
151
-
152
- def _tts_worker(sentence: str, full_text: str, slot: queue.Queue):
153
- clean = _strip_for_speech(sentence)
154
- if not clean:
155
- slot.put((None, "neutral", sentence, full_text))
156
- return
157
- audio, emotion = speak_to_file(clean)
158
- slot.put((audio, emotion, sentence, full_text))
159
-
160
- # Collect sentences and fire TTS threads
161
- def _dispatch_thread():
162
- while True:
163
- kind, sentence, full_text = sentence_q.get()
164
- if kind == "done":
165
- final_text[0] = full_text
166
- llm_done.set()
167
- break
168
- slot: queue.Queue = queue.Queue(maxsize=1)
169
- slots.append(slot)
170
- threading.Thread(
171
- target=_tts_worker,
172
- args=(sentence, full_text, slot),
173
- daemon=True,
174
- ).start()
175
-
176
- threading.Thread(target=_dispatch_thread, daemon=True).start()
177
-
178
- # ── Stage 3: drain slots in order, yield text+audio together ─────────────
179
- # Show a "thinking" indicator while waiting for first sentence
180
- displayed_text = ""
181
- slot_idx = 0
182
-
183
- while True:
184
- # Check if new slots have appeared
185
- if slot_idx < len(slots):
186
- slot = slots[slot_idx]
187
- audio, emotion, sentence, full_text_snapshot = slot.get() # blocks until TTS done
188
-
189
- # Reveal this sentence's text in sync with its audio
190
- displayed_text += sentence + " "
191
- history[-1]["content"] = displayed_text.strip()
192
-
193
- yield (
194
- history,
195
- f"EMOTION:{emotion}|{_strip_for_speech(sentence)}",
196
- audio,
197
- )
198
- slot_idx += 1
199
-
200
- elif llm_done.is_set() and slot_idx >= len(slots):
201
- # All sentences dispatched and drained
202
- break
203
  else:
204
- # Still waiting for more sentences or TTS
205
- time.sleep(0.05)
206
 
207
- # Final cleanup — ensure full text is shown (covers edge case of no sentences)
208
- if final_text[0] and history[-1]["content"] != final_text[0]:
209
- history[-1]["content"] = final_text[0]
210
 
211
- yield history, None, None
 
 
212
 
213
- if llm_error:
214
- raise llm_error["e"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
 
217
- # ─────────────────────────────────────────────
218
- # WRAPPERS
219
- # ─────────────────────────────────────────────
220
  def _submit(message, history):
221
  history = history or []
222
  message = (message or "").strip()
@@ -226,9 +136,9 @@ def _submit(message, history):
226
  return
227
 
228
  first = True
229
- for h, tts, audio in _stream_response(message, history):
230
  if first:
231
- yield h, tts, audio, ""
232
  first = False
233
  else:
234
  yield h, tts, audio, gr.update()
@@ -241,11 +151,10 @@ def voice_chat(audio_path, history):
241
  return history, None, None
242
 
243
  transcript = transcribe_file(audio_path)
244
-
245
  if not transcript:
246
  return history, None, None
247
 
248
- for h, tts, audio in _stream_response(transcript, history):
249
  if h and len(h) >= 2:
250
  h[-2]["content"] = f"🎙️ {transcript}"
251
  yield h, tts, audio
@@ -391,7 +300,7 @@ with gr.Blocks(
391
  // Find and click whatever button starts recording
392
  const buttons = audioContainer.querySelectorAll('button');
393
  buttons.forEach(b => {
394
- if (b.title?.toLowerCase().includes('record') ||
395
  b.getAttribute('aria-label')?.toLowerCase().includes('record')) {
396
  b.click();
397
  }
@@ -402,7 +311,7 @@ with gr.Blocks(
402
  // Find and click stop
403
  const buttons = audioContainer.querySelectorAll('button');
404
  buttons.forEach(b => {
405
- if (b.title?.toLowerCase().includes('stop') ||
406
  b.getAttribute('aria-label')?.toLowerCase().includes('stop')) {
407
  b.click();
408
  }
@@ -414,6 +323,127 @@ with gr.Blocks(
414
  """
415
  )
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  demo.queue()
418
 
419
  # ─────────────────────────────────────────────
 
61
  # HELPERS
62
  # ─────────────────────────────────────────────
63
  def _strip_for_speech(text: str) -> str:
64
+ """Remove search notes, tool tags, and think blocks before passing to TTS."""
65
  text = re.sub(r"\n?🔍 Searching: \*.*?\*\n?", "", text)
66
  text = re.sub(r"\n?🔧 .*?\n?", "", text)
67
  text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
68
  return text.strip()
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  # ─────────────────────────────────────────────
72
+ # CORE RESPONSE (full text → TTS → typewriter)
73
  # ─────────────────────────────────────────────
 
74
 
75
+ def _get_response(message: str, history: list):
 
76
  """
77
+ 1. Show user message + thinking cursor immediately.
78
+ 2. Run LLM to full completion (no streaming to UI).
79
+ 3. Synthesize the complete response as one TTS pass.
80
+ 4. Yield: chatbot with empty assistant bubble + TYPEWRITE signal + audio.
81
+ The iframe JS will typewrite the text into the chatbot bubble in sync
82
+ with the audio duration.
83
  """
84
  history = list(history) + [
85
+ {"role": "user", "content": message},
86
  {"role": "assistant", "content": "▋"},
87
  ]
88
+ # Show user message + thinking cursor right away
89
  yield history, None, None
90
 
91
+ # ── Stage 1: full LLM completion ────────────────────────────────────────
92
+ full_text = ""
93
+ search_notes: list[str] = []
94
+
95
+ def _cb(token: str):
96
+ nonlocal full_text
97
+ if token.startswith("__SEARCHING__:"):
98
+ q = token.split(":", 1)[1]
99
+ search_notes.append(f"🔍 Searching: *{q}*")
100
+ elif token.startswith("__TOOL__:"):
101
+ search_notes.append(f"🔧 {token.split(':', 1)[1]}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  else:
103
+ full_text += token
 
104
 
105
+ think.chat(message, token_callback=_cb)
 
 
106
 
107
+ # Build the display text shown in the chatbot bubble (notes + answer)
108
+ notes_prefix = "\n".join(search_notes) + "\n\n" if search_notes else ""
109
+ display_text = notes_prefix + full_text
110
 
111
+ # ── Stage 2: TTS on clean speech text ────────────────────────────────────
112
+ speech_text = _strip_for_speech(full_text)
113
+ if speech_text:
114
+ audio_path, emotion = speak_to_file(speech_text)
115
+ else:
116
+ audio_path, emotion = None, "neutral"
117
+
118
+ # ── Stage 3: signal the iframe to typewrite text in sync with audio ───────
119
+ # Chatbot bubble starts empty — JS will fill it character by character.
120
+ # Format packed into the hidden tts_text field:
121
+ # TYPEWRITE:<emotion>|<display_text>
122
+ # The iframe reads audio.duration once metadata loads, then paces chars
123
+ # so the typewriter finishes exactly when the audio ends.
124
+ history[-1]["content"] = "" # blank; JS owns this from here
125
+
126
+ signal = f"TYPEWRITE:{emotion}|{display_text}"
127
+ yield history, signal, audio_path
128
 
129
 
 
 
 
130
  def _submit(message, history):
131
  history = history or []
132
  message = (message or "").strip()
 
136
  return
137
 
138
  first = True
139
+ for h, tts, audio in _get_response(message, history):
140
  if first:
141
+ yield h, tts, audio, "" # clear input on first yield
142
  first = False
143
  else:
144
  yield h, tts, audio, gr.update()
 
151
  return history, None, None
152
 
153
  transcript = transcribe_file(audio_path)
 
154
  if not transcript:
155
  return history, None, None
156
 
157
+ for h, tts, audio in _get_response(transcript, history):
158
  if h and len(h) >= 2:
159
  h[-2]["content"] = f"🎙️ {transcript}"
160
  yield h, tts, audio
 
300
  // Find and click whatever button starts recording
301
  const buttons = audioContainer.querySelectorAll('button');
302
  buttons.forEach(b => {
303
+ if (b.title?.toLowerCase().includes('record') ||
304
  b.getAttribute('aria-label')?.toLowerCase().includes('record')) {
305
  b.click();
306
  }
 
311
  // Find and click stop
312
  const buttons = audioContainer.querySelectorAll('button');
313
  buttons.forEach(b => {
314
+ if (b.title?.toLowerCase().includes('stop') ||
315
  b.getAttribute('aria-label')?.toLowerCase().includes('stop')) {
316
  b.click();
317
  }
 
323
  """
324
  )
325
 
326
+ # ── Typewriter bridge ────────────────────────────────────────────────────
327
+ # When tts_text changes to a TYPEWRITE: signal, inject JS that:
328
+ # 1. Stores the full display text on window for the iframe to also use
329
+ # (caption bar, lip-sync text source).
330
+ # 2. Waits for the <audio> element's metadata to know its duration.
331
+ # 3. Types characters into the last chatbot assistant bubble at a pace
332
+ # that finishes exactly when audio ends (min 18ms/char so it's readable).
333
+ tts_text.change(
334
+ None,
335
+ inputs=[tts_text],
336
+ js="""
337
+ (rawSignal) => {
338
+ if (!rawSignal || !rawSignal.startsWith('TYPEWRITE:')) return;
339
+
340
+ const rest = rawSignal.slice('TYPEWRITE:'.length);
341
+ const pipeIdx = rest.indexOf('|');
342
+ const emotion = rest.slice(0, pipeIdx);
343
+ const fullText = rest.slice(pipeIdx + 1);
344
+
345
+ // ── 1. Pass text + emotion to the VRM iframe ──────────────────
346
+ const iframe = document.querySelector('#aiko-vrm-frame');
347
+ if (iframe?.contentWindow) {
348
+ iframe.contentWindow.postMessage(
349
+ JSON.stringify({ expression: emotion, ttsText: fullText }),
350
+ '*'
351
+ );
352
+ }
353
+ // Also stash on window so the iframe's poll can find it
354
+ window._aikoLatestTtsText = fullText;
355
+
356
+ // ── 2. Find the last assistant bubble in the chatbot ──────────
357
+ // Gradio 4/5 uses .message.bot; Gradio 3 uses .bot.
358
+ // We look for the last rendered bot bubble's inner paragraph.
359
+ function getLastBubble() {
360
+ // Try Gradio 4/5 messages format first
361
+ const bubbles = document.querySelectorAll(
362
+ '#aiko-chatbot .message.bot p, ' +
363
+ '#aiko-chatbot [data-testid="bot"] p, ' +
364
+ '#aiko-chatbot .bot p'
365
+ );
366
+ return bubbles.length ? bubbles[bubbles.length - 1] : null;
367
+ }
368
+
369
+ // ── 3. Typewriter function, paced to audio duration ───────────
370
+ let twTimer = null;
371
+
372
+ function runTypewriter(duration) {
373
+ const bubble = getLastBubble();
374
+ if (!bubble) {
375
+ // Bubble not rendered yet — retry briefly
376
+ setTimeout(() => runTypewriter(duration), 80);
377
+ return;
378
+ }
379
+
380
+ const chars = fullText.length;
381
+ // Aim to finish slightly before audio ends (×0.92 buffer)
382
+ const msPerChar = Math.max(18, (duration * 1000 * 0.92) / chars);
383
+
384
+ bubble.textContent = '';
385
+ let i = 0;
386
+
387
+ clearInterval(twTimer);
388
+ twTimer = setInterval(() => {
389
+ if (i < chars) {
390
+ bubble.textContent = fullText.slice(0, ++i);
391
+ // Auto-scroll the chatbot to bottom
392
+ const chatScroll = document.querySelector('#aiko-chatbot > div');
393
+ if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
394
+ } else {
395
+ clearInterval(twTimer);
396
+ }
397
+ }, msPerChar);
398
+ }
399
+
400
+ // ── 4. Wait for audio element + its duration metadata ─────────
401
+ function waitForAudioAndType() {
402
+ const audioEl = document.querySelector('#aiko-audio audio');
403
+
404
+ if (!audioEl) {
405
+ // Audio element not in DOM yet — poll briefly
406
+ setTimeout(waitForAudioAndType, 100);
407
+ return;
408
+ }
409
+
410
+ function start() {
411
+ const dur = audioEl.duration;
412
+ if (Number.isFinite(dur) && dur > 0) {
413
+ runTypewriter(dur);
414
+ } else {
415
+ // Fallback: estimate ~0.075s per character
416
+ const estimated = Math.max(2, fullText.length * 0.075);
417
+ runTypewriter(estimated);
418
+ }
419
+ }
420
+
421
+ if (audioEl.readyState >= 1 && Number.isFinite(audioEl.duration) && audioEl.duration > 0) {
422
+ start();
423
+ } else {
424
+ // Wait for metadata, but also set a fallback timeout
425
+ const onMeta = () => {
426
+ audioEl.removeEventListener('loadedmetadata', onMeta);
427
+ start();
428
+ };
429
+ audioEl.addEventListener('loadedmetadata', onMeta);
430
+ // Safety net: if metadata never fires (e.g. no audio), start anyway
431
+ setTimeout(() => {
432
+ audioEl.removeEventListener('loadedmetadata', onMeta);
433
+ if (audioEl.duration > 0) {
434
+ start();
435
+ } else {
436
+ runTypewriter(Math.max(2, fullText.length * 0.075));
437
+ }
438
+ }, 600);
439
+ }
440
+ }
441
+
442
+ waitForAudioAndType();
443
+ }
444
+ """
445
+ )
446
+
447
  demo.queue()
448
 
449
  # ─────────────────────────────────────────────
core/think.py CHANGED
@@ -94,10 +94,13 @@ class AikoThink:
94
  "/",
95
  json={
96
  "model": LLAMA_MODEL,
97
- "max_tokens": 1,
98
- "messages": [{"role": "user", "content": "hi"}],
 
99
  },
 
100
  )
 
101
  except Exception as e:
102
  log.warning("LLM warmup failed: %s", e)
103
 
 
94
  "/",
95
  json={
96
  "model": LLAMA_MODEL,
97
+ "max_tokens": 50, # enough to force real inference
98
+ "messages": [{"role": "user", "content": "Say hello briefly."}],
99
+ "temperature": 0.1,
100
  },
101
+ timeout=120,
102
  )
103
+ log.info("LLM warmup complete")
104
  except Exception as e:
105
  log.warning("LLM warmup failed: %s", e)
106
 
ui/speak.py CHANGED
@@ -118,17 +118,10 @@ async def _edge_tts_to_file(text: str, out_path: Path) -> None:
118
  )
119
  await communicate.save(str(out_path))
120
 
121
-
122
  def _miotts_to_file(text: str, out_path: Path) -> None:
123
- """Call MioTTS /v1/tts/file (multipart/form-data) → write WAV then convert to MP3."""
124
  import httpx
125
- from pydub import AudioSegment
126
- import io
127
 
128
- # /v1/tts/file accepts multipart form fields:
129
- # text — required
130
- # reference_preset_id — preset name registered via register_preset_cli
131
- # output_format — "wav" returns raw audio/wav bytes directly
132
  data = {
133
  "text": text,
134
  "reference_preset_id": MIOTTS_PRESET_ID,
@@ -142,17 +135,14 @@ def _miotts_to_file(text: str, out_path: Path) -> None:
142
  )
143
  resp.raise_for_status()
144
 
145
- # Response is raw WAV bytes (audio/wav)convert to MP3 for Gradio browser playback
146
- wav_bytes = io.BytesIO(resp.content)
147
- audio = AudioSegment.from_wav(wav_bytes)
148
- audio.export(str(out_path), format="mp3")
149
-
150
 
151
  def _synth_to_file(clean: str) -> str | None:
152
- """Synthesize cleaned text, return MP3 path or None on failure."""
153
  TTS_DIR.mkdir(parents=True, exist_ok=True)
154
  digest = hashlib.sha1(f"{time.time_ns()}:{clean}".encode()).hexdigest()[:16]
155
- out_path = TTS_DIR / f"aiko_{digest}.mp3"
156
  try:
157
  if MIOTTS_URL:
158
  _miotts_to_file(clean[:4000], out_path)
 
118
  )
119
  await communicate.save(str(out_path))
120
 
 
121
  def _miotts_to_file(text: str, out_path: Path) -> None:
122
+ """Call MioTTS /v1/tts/file (multipart/form-data) → write WAV."""
123
  import httpx
 
 
124
 
 
 
 
 
125
  data = {
126
  "text": text,
127
  "reference_preset_id": MIOTTS_PRESET_ID,
 
135
  )
136
  resp.raise_for_status()
137
 
138
+ # Write WAV bytes directlyno conversion needed
139
+ out_path.write_bytes(resp.content)
 
 
 
140
 
141
  def _synth_to_file(clean: str) -> str | None:
142
+ """Synthesize cleaned text, return WAV path or None on failure."""
143
  TTS_DIR.mkdir(parents=True, exist_ok=True)
144
  digest = hashlib.sha1(f"{time.time_ns()}:{clean}".encode()).hexdigest()[:16]
145
+ out_path = TTS_DIR / f"aiko_{digest}.wav"
146
  try:
147
  if MIOTTS_URL:
148
  _miotts_to_file(clean[:4000], out_path)
ui/vrm.py CHANGED
@@ -31,10 +31,16 @@ def gradio_file_url(path: Path) -> str:
31
 
32
  def avatar_html(vrm_urls: str | list[str]) -> str:
33
  """Return an iframe containing the Three/VRM viewer.
 
34
  Camera is framed to a half-body shot (waist-up). The iframe exposes a
35
- postMessage API for expression, viseme, and ttsText/caption control.
36
- An internal caption bar at the bottom of the canvas streams assistant
37
- text as closed captions during audio playback.
 
 
 
 
 
38
  """
39
  if isinstance(vrm_urls, str):
40
  vrm_urls = [vrm_urls]
@@ -91,7 +97,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
91
  position: fixed;
92
  bottom: 0;
93
  left: 0;
94
- right: 40%; /* leaves room for external chat overlay on the right */
95
  min-height: 50px;
96
  max-height: 108px;
97
  padding: 10px 20px 14px 18px;
@@ -150,15 +156,15 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
150
  import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js';
151
  import {{ VRMLoaderPlugin, VRMUtils }} from '@pixiv/three-vrm';
152
 
153
- // ── Hard-lock this iframe's document height ──
154
  document.documentElement.style.setProperty('height', '100vh', 'important');
155
  document.documentElement.style.setProperty('overflow', 'hidden', 'important');
156
  document.body.style.setProperty('height', '100vh', 'important');
157
  document.body.style.setProperty('overflow', 'hidden', 'important');
158
  document.body.style.setProperty('max-height', '100vh', 'important');
159
-
160
- const RAW_VRM_URLS = {vrm_urls!r};
161
- const VISEME_MAP = {{ A: 'aa', I: 'ih', U: 'ou', E: 'ee', O: 'oh' }};
162
  const VISEME_PRESETS = ['aa', 'ih', 'ou', 'ee', 'oh'];
163
  const TEXT_VISEME_MAP = {{
164
  a: 'aa', á: 'aa', à: 'aa', â: 'aa', ä: 'aa', あ: 'aa', ア: 'aa', か: 'aa', カ: 'aa', さ: 'aa', サ: 'aa', た: 'aa', タ: 'aa', な: 'aa', ナ: 'aa', は: 'aa', ハ: 'aa', ま: 'aa', マ: 'aa', や: 'aa', ヤ: 'aa', ら: 'aa', ラ: 'aa', わ: 'aa', ワ: 'aa',
@@ -167,6 +173,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
167
  e: 'ee', é: 'ee', è: 'ee', ê: 'ee', ë: 'ee', え: 'ee', エ: 'ee', け: 'ee', ケ: 'ee', せ: 'ee', セ: 'ee', て: 'ee', テ: 'ee', ね: 'ee', ネ: 'ee', へ: 'ee', ヘ: 'ee', め: 'ee', メ: 'ee', れ: 'ee', レ: 'ee',
168
  o: 'oh', ó: 'oh', ò: 'oh', ô: 'oh', ö: 'oh', お: 'oh', オ: 'oh', こ: 'oh', コ: 'oh', そ: 'oh', ソ: 'oh', と: 'oh', ト: 'oh', の: 'oh', ノ: 'oh', ほ: 'oh', ホ: 'oh', も: 'oh', モ: 'oh', よ: 'oh', ヨ: 'oh', ろ: 'oh', ロ: 'oh', を: 'oh', ヲ: 'oh', ん: 'oh', ン: 'oh',
169
  }};
 
170
  function withTrailingSlash(url) {{ return url.endsWith('/') ? url : url + '/'; }}
171
  function buildVrmUrls(rawUrls) {{
172
  const urls = [];
@@ -185,15 +192,14 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
185
  }}
186
  return [...new Set(urls)];
187
  }}
 
188
  const VRM_URLS = buildVrmUrls(RAW_VRM_URLS);
189
- const canvas = document.getElementById('canvas');
190
  const renderer = new THREE.WebGLRenderer({{ canvas, antialias: true, alpha: true }});
191
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
192
  renderer.outputColorSpace = THREE.SRGBColorSpace;
 
193
  const scene = new THREE.Scene();
194
- // ── Half-body camera framing ──────────────────────────────────────────────
195
- // Position: slightly closer, raised to ~waist level focus.
196
- // FOV narrowed to 22° to reduce perspective distortion on a close portrait shot.
197
  const camera = new THREE.PerspectiveCamera(22, 1, 0.1, 100);
198
  camera.position.set(0.30, 1.18, 2.1);
199
  const controls = new OrbitControls(camera, canvas);
@@ -202,7 +208,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
202
  controls.enablePan = false;
203
  controls.minDistance = 1.0;
204
  controls.maxDistance = 3.2;
205
- // ─────────────────────────────────────────────────────────────────────────
206
  scene.add(new THREE.HemisphereLight(0xded4ff, 0x21182f, 2.4));
207
  const key = new THREE.DirectionalLight(0xffffff, 2.7);
208
  key.position.set(1.8, 3.0, 2.5);
@@ -210,8 +216,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
210
  const rim = new THREE.DirectionalLight(0x9b7cff, 1.5);
211
  rim.position.set(-2.5, 1.4, -1.2);
212
  scene.add(rim);
213
- // No floor grid — cleaner for half-body portrait
214
- // scene.add(new THREE.GridHelper(10, 20, 0x1a0a2a, 0x100820));
215
  let vrm = null;
216
  let mouth = 0;
217
  let smoothedAudioMouth = 0;
@@ -229,6 +234,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
229
  let speechVisemes = [];
230
  let speechStartedAt = 0;
231
  let speechDuration = 0;
 
232
  const REST = window._REST = {{
233
  leftUpperArm: {{ x: 0.02, y: 0.0, z: -1.28 }},
234
  rightUpperArm: {{ x: 0.02, y: 0.0, z: 1.28 }},
@@ -237,15 +243,19 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
237
  leftHand: {{ x: 0.0, y: 0.08, z: 0.0 }},
238
  rightHand: {{ x: 0.0, y:-0.08, z: 0.0 }},
239
  }};
 
240
  const dot = document.getElementById('dot');
241
  const statusText = document.getElementById('status-text');
242
  const emotionEl = document.getElementById('emotion');
243
  const captionBar = document.getElementById('caption-bar');
244
  const captionText = document.getElementById('caption-text');
 
245
  // ── Caption streaming ─────────────────────────────────────────────────────
 
246
  let captionWords = [];
247
  let captionIdx = 0;
248
  let captionTimer = null;
 
249
  function startCaption(text) {{
250
  clearInterval(captionTimer);
251
  captionWords = text.trim().split(/\s+/).filter(Boolean);
@@ -253,28 +263,31 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
253
  captionText.textContent = '';
254
  captionBar.classList.remove('hidden');
255
  if (!captionWords.length) return;
256
- // Pace: reveal ~2-3 words at a time proportional to speech duration
257
  const totalWords = captionWords.length;
258
- const totalSeconds = (lastAudio && lastAudio.duration > 0) ? lastAudio.duration : Math.max(2, totalWords * 0.38);
259
- const msPerWord = (totalSeconds * 1000) / totalWords;
 
 
 
260
  captionTimer = setInterval(() => {{
261
  if (captionIdx >= captionWords.length) {{
262
  clearInterval(captionTimer);
263
- // fade out after a short hold
264
  setTimeout(() => {{ captionBar.classList.add('hidden'); }}, 1800);
265
  return;
266
  }}
267
- // Show a rolling window of the last ~12 words
268
  const windowEnd = captionIdx + 1;
269
  const windowStart = Math.max(0, windowEnd - 12);
270
  captionText.textContent = captionWords.slice(windowStart, windowEnd).join(' ');
271
  captionIdx++;
272
  }}, msPerWord);
273
  }}
 
274
  function stopCaption() {{
275
  clearInterval(captionTimer);
276
  setTimeout(() => captionBar.classList.add('hidden'), 1200);
277
  }}
 
278
  // ─────────────────────────────────────────────────────────────────────────
279
  function nextBlinkWait() {{ return 3.0 + Math.random() * 4.0; }}
280
  function expressionNames() {{
@@ -285,17 +298,19 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
285
  function safeSetExpression(name, weight) {{
286
  try {{ vrm?.expressionManager?.setValue(name, weight); }} catch (_) {{}}
287
  }}
 
288
  let lastW = 0, lastH = 0;
289
  function resize() {{
290
- const w = Math.max(1, Math.min(window.screen.width, canvas.clientWidth || window.innerWidth));
291
- const h = Math.max(1, Math.min(window.screen.height, canvas.clientHeight || window.innerHeight));
292
- if (w === lastW && h === lastH) return;
293
- lastW = w; lastH = h;
294
- renderer.setSize(w, h, false);
295
- camera.aspect = w / h;
296
- camera.updateProjectionMatrix();
297
  }}
298
  addEventListener('resize', resize);
 
299
  function setExpression(name, weight = 1) {{
300
  if (!vrm?.expressionManager) return;
301
  for (const k of ['happy', 'relaxed', 'angry', 'sad', 'surprised']) {{
@@ -303,6 +318,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
303
  }}
304
  emotionEl.textContent = name || 'neutral';
305
  }}
 
306
  function setMouth(weight, viseme = 'aa') {{
307
  if (!vrm) return;
308
  const clamped = Math.max(0, Math.min(1, Number(weight) || 0));
@@ -327,7 +343,9 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
327
  if (jaw) jaw.rotation.x = clamped * 0.5;
328
  }}
329
  }}
 
330
  function clearMouth() {{ setMouth(0, 'aa'); }}
 
331
  function setSpeaking(active) {{
332
  speaking = Boolean(active);
333
  dot.className = speaking ? 'speaking' : '';
@@ -335,12 +353,14 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
335
  setExpression(speaking ? 'happy' : 'relaxed', speaking ? 0.55 : 0.25);
336
  if (!speaking) {{ clearMouth(); stopCaption(); }}
337
  }}
 
338
  function estimateSpeechDuration(text, requestedDuration = null) {{
339
  const explicit = Number(requestedDuration);
340
  if (Number.isFinite(explicit) && explicit > 0) return explicit;
341
  if (lastAudio && Number.isFinite(lastAudio.duration) && lastAudio.duration > 0) return lastAudio.duration;
342
  return Math.max(1.0, Math.min(12, text.length * 0.075));
343
  }}
 
344
  function textToVisemes(text) {{
345
  const tokens = [];
346
  let lastViseme = 'aa';
@@ -357,6 +377,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
357
  }}
358
  return tokens.length ? tokens : [{{ viseme: 'aa', weight: 0.25 }}];
359
  }}
 
360
  function setSpeechText(text, duration = null) {{
361
  const nextText = String(text || '').trim();
362
  if (!nextText) return;
@@ -364,14 +385,17 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
364
  speechVisemes = textToVisemes(speechText);
365
  speechDuration = estimateSpeechDuration(speechText, duration);
366
  speechStartedAt = performance.now();
367
- // Start closed captions
368
  startCaption(speechText);
369
  }}
 
370
  function currentTextMouth(now) {{
371
  if (!speechVisemes.length) return null;
372
- const audioDuration = lastAudio && Number.isFinite(lastAudio.duration) && lastAudio.duration > 0 ? lastAudio.duration : speechDuration;
 
373
  const duration = Math.max(0.25, audioDuration || speechDuration || 1);
374
- const elapsed = lastAudio && !lastAudio.paused ? lastAudio.currentTime : (now - speechStartedAt) / 1000;
 
 
375
  const progress = Math.max(0, Math.min(0.999, elapsed / duration));
376
  const index = Math.min(speechVisemes.length - 1, Math.floor(progress * speechVisemes.length));
377
  const token = speechVisemes[index];
@@ -381,23 +405,13 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
381
  weight: Math.max(0, Math.min(1, token.weight * (0.55 + Math.abs(syllablePhase) * 0.45))),
382
  }};
383
  }}
384
- function findParentSpeechText() {{
385
- if (window._aikoLatestTtsText) {{
386
- const t = window._aikoLatestTtsText;
387
- window._aikoLatestTtsText = '';
388
- return t;
389
- }}
390
- try {{
391
- const doc = parent.document;
392
- const el = doc.querySelector('#aiko-tts-text textarea, #aiko-tts-text input');
393
- return el ? (el.value || el.textContent || '') : '';
394
- }} catch (_) {{ return ''; }}
395
- }}
396
  function getBone(name) {{
397
  const h = vrm?.humanoid;
398
  if (!h) return null;
399
  return h.getRawBoneNode?.(name) || h.getNormalizedBoneNode?.(name) || null;
400
  }}
 
401
  function applyIdle(dt) {{
402
  if (!vrm?.humanoid) return;
403
  idleTime += dt;
@@ -434,10 +448,11 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
434
  if (rUA) {{ rUA.rotation.x = REST.rightUpperArm.x + Math.sin(idleTime * 0.53 + 0.9) * 0.010; rUA.rotation.y = REST.rightUpperArm.y + Math.sin(idleTime * 0.35 + 0.4) * 0.006; rUA.rotation.z = REST.rightUpperArm.z + Math.sin(idleTime * 0.37 + 0.7) * 0.008; }}
435
  if (lLA) {{ lLA.rotation.x = REST.leftLowerArm.x + Math.sin(idleTime * 0.61) * 0.008; lLA.rotation.y = REST.leftLowerArm.y; lLA.rotation.z = REST.leftLowerArm.z + Math.sin(idleTime * 0.43) * 0.004; }}
436
  if (rLA) {{ rLA.rotation.x = REST.rightLowerArm.x + Math.sin(idleTime * 0.57 + 1.4) * 0.008; rLA.rotation.y = REST.rightLowerArm.y; rLA.rotation.z = REST.rightLowerArm.z + Math.sin(idleTime * 0.51 + 0.5) * 0.004; }}
437
- if (lH) {{ lH.rotation.x = REST.leftHand.x; lH.rotation.y = REST.leftHand.y + Math.sin(idleTime * 0.33) * 0.008; lH.rotation.z = REST.leftHand.z; }}
438
- if (rH) {{ rH.rotation.x = REST.rightHand.x; rH.rotation.y = REST.rightHand.y + Math.sin(idleTime * 0.29 + 1.2) * 0.008; rH.rotation.z = REST.rightHand.z; }}
439
  }}
440
- // ── Idle gesture state machine ───────────────────────────────────────────
 
441
  let gestureState = 'none';
442
  let gestureT = 0;
443
  let gestureDuration = 0;
@@ -445,14 +460,10 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
445
  let gestureTarget = null;
446
  const GESTURES = ['lookAround', 'sideGlance', 'meetGaze', 'curiousTilt', 'shiftWeight', 'hairTuck', 'stretchNeck'];
447
  const GESTURE_DURATION = {{
448
- lookAround: 3.2,
449
- sideGlance: 2.2,
450
- meetGaze: 2.8,
451
- curiousTilt: 2.4,
452
- shiftWeight: 3.2,
453
- hairTuck: 2.7,
454
- stretchNeck: 2.6,
455
  }};
 
456
  function pickGesture() {{
457
  const g = GESTURES[Math.floor(Math.random() * GESTURES.length)];
458
  gestureState = g;
@@ -466,12 +477,14 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
466
  hairSide: side,
467
  }};
468
  }}
 
469
  function easeInOutSine(t) {{ return -(Math.cos(Math.PI * t) - 1) / 2; }}
470
  function holdCurve(progress, inPortion = 0.28, outPortion = 0.30) {{
471
  if (progress < inPortion) return easeInOutSine(progress / inPortion);
472
  if (progress > 1 - outPortion) return easeInOutSine((1 - progress) / outPortion);
473
  return 1;
474
  }}
 
475
  function applyGestures(dt) {{
476
  if (!vrm?.humanoid) return;
477
  if (speaking) {{ gestureState = 'none'; return; }}
@@ -491,43 +504,27 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
491
  const spine = getBone('spine'), hips = getBone('hips');
492
  switch (gestureState) {{
493
  case 'lookAround':
494
- if (head) {{
495
- head.rotation.y += gestureTarget.lookAround * held;
496
- head.rotation.x += Math.sin(eased * Math.PI) * 0.025;
497
- }}
498
- if (neck) neck.rotation.y += gestureTarget.lookAround * 0.22 * held;
499
  break;
500
  case 'sideGlance':
501
- if (head) {{
502
- head.rotation.y += gestureTarget.sideGlance * held;
503
- head.rotation.z -= gestureTarget.sideGlance * 0.18 * intensity;
504
- }}
505
  safeSetExpression('relaxed', 0.28);
506
  break;
507
  case 'meetGaze':
508
- if (head) {{
509
- head.rotation.y *= 1 - held * 0.78;
510
- head.rotation.z *= 1 - held * 0.70;
511
- head.rotation.x += held * 0.018;
512
- }}
513
- if (neck) {{
514
- neck.rotation.y *= 1 - held * 0.55;
515
- neck.rotation.z *= 1 - held * 0.55;
516
- }}
517
  safeSetExpression('relaxed', 0.30);
518
  safeSetExpression('happy', 0.04 * held);
519
  break;
520
  case 'curiousTilt':
521
- if (head) {{
522
- head.rotation.z += gestureTarget.curiousTilt * held;
523
- head.rotation.x -= 0.018 * intensity;
524
- }}
525
- if (neck) neck.rotation.z += gestureTarget.curiousTilt * 0.45 * held;
526
  break;
527
  case 'shiftWeight':
528
- if (hips) hips.position.x += Math.sin(eased * Math.PI) * 0.016;
529
  if (spine) spine.rotation.z += Math.sin(eased * Math.PI) * 0.018;
530
- if (head) head.rotation.z -= Math.sin(eased * Math.PI) * 0.012;
531
  break;
532
  case 'hairTuck':
533
  if (gestureTarget.hairSide < 0) {{
@@ -539,10 +536,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
539
  if (rLA) {{ rLA.rotation.x = REST.rightLowerArm.x - intensity * 0.26; rLA.rotation.z = REST.rightLowerArm.z + intensity * 0.10; }}
540
  if (rH) {{ rH.rotation.y = REST.rightHand.y - intensity * 0.16; rH.rotation.z = REST.rightHand.z + intensity * 0.10; }}
541
  }}
542
- if (head) {{
543
- head.rotation.z -= gestureTarget.hairSide * intensity * 0.035;
544
- head.rotation.y += gestureTarget.hairSide * intensity * 0.025;
545
- }}
546
  break;
547
  case 'stretchNeck':
548
  if (neck) neck.rotation.x += -intensity * 0.05;
@@ -551,7 +545,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
551
  }}
552
  if (progress >= 1) gestureState = 'none';
553
  }}
554
- // ─────────────────────────────────────────────────────────────────────────
555
  function applyBlink(dt) {{
556
  if (!vrm?.expressionManager) return;
557
  if (blinkPhase === 'wait') {{
@@ -570,6 +564,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
570
  }}
571
  }}
572
  }}
 
573
  let lastAudio = null;
574
  let audioContext = null;
575
  let analyserAudio = null;
@@ -577,15 +572,16 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
577
  let audioSource = null;
578
  let audioMeterOk = false;
579
  let meteredAudio = null;
 
580
  function setupAudioMeter(audio) {{
581
- if (!audio || audioMeterOk && audio === meteredAudio) return;
582
  try {{
583
  audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)();
584
  if (audioContext.state === 'suspended') audioContext.resume().catch(() => {{}});
585
  analyserAudio = audioContext.createAnalyser();
586
  analyserAudio.fftSize = 512;
587
  analyserAudio.smoothingTimeConstant = 0.72;
588
- audioData = new Uint8Array(analyserAudio.fftSize);
589
  audioSource = audio._aikoMediaSource || audioContext.createMediaElementSource(audio);
590
  audio._aikoMediaSource = audioSource;
591
  audioSource.connect(analyserAudio);
@@ -600,6 +596,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
600
  audioData = null;
601
  }}
602
  }}
 
603
  function getAudioMouth() {{
604
  if (!analyserAudio || !audioData) return null;
605
  analyserAudio.getByteTimeDomainData(audioData);
@@ -608,76 +605,101 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
608
  const centered = (audioData[i] - 128) / 128;
609
  sum += centered * centered;
610
  }}
611
- const rms = Math.sqrt(sum / audioData.length);
612
- const gated = Math.max(0, rms - 0.018);
613
  const target = Math.min(1, Math.pow(gated * 7.5, 0.72));
614
  smoothedAudioMouth += (target - smoothedAudioMouth) * (target > smoothedAudioMouth ? 0.55 : 0.28);
615
  return smoothedAudioMouth;
616
  }}
 
617
  function findParentAudio() {{
618
  try {{ return parent.document.querySelector('#aiko-audio audio') || parent.document.querySelector('audio'); }}
619
  catch (_) {{ return null; }}
620
  }}
 
621
  function syncAudioState(audio) {{
622
  if (!audio) return;
623
  setSpeaking(!audio.paused && !audio.ended && audio.currentTime >= 0);
624
  }}
 
625
  function attachAudio(audio) {{
626
  if (!audio) return;
627
  if (audio !== lastAudio) {{
628
  lastAudio = audio;
 
629
  audio.addEventListener('play', () => {{
630
  setSpeaking(true);
631
  setupAudioMeter(audio);
632
- let tries = 0;
633
- const poll = setInterval(() => {{
634
- const text = findParentSpeechText();
635
- if (text) {{
636
- clearInterval(poll);
637
- setSpeechText(text, audio.duration);
638
- }} else if (++tries >= 10) {{
639
- clearInterval(poll);
640
- }}
641
- }}, 200);
642
  }});
 
643
  audio.addEventListener('playing', () => {{
644
  setSpeaking(true);
645
  setupAudioMeter(audio);
646
- const text = findParentSpeechText();
647
- if (text) setSpeechText(text, audio.duration);
 
 
 
 
 
 
648
  }});
649
- audio.addEventListener('timeupdate', () => {{ if (!audio.paused && audio.currentTime > 0) setSpeaking(true); }});
650
  audio.addEventListener('pause', () => setSpeaking(false));
651
  audio.addEventListener('ended', () => setSpeaking(false));
652
  }}
653
  syncAudioState(audio);
654
  }}
 
655
  setInterval(() => attachAudio(findParentAudio()), 500);
 
 
 
 
656
  window.addEventListener('message', (e) => {{
657
  try {{
658
  const msg = (typeof e.data === 'string') ? JSON.parse(e.data) : e.data;
659
- if (msg.speaking !== undefined) setSpeaking(msg.speaking);
 
660
  if (msg.expression !== undefined) {{
661
  setExpression(msg.expression, msg.intensity ?? 1.0);
662
  clearTimeout(exprResetTimer);
663
- if (msg.expression && msg.expression !== 'neutral')
664
  exprResetTimer = setTimeout(() => setExpression('relaxed', 0.25), EXPR_RESET_DELAY);
 
665
  }}
 
 
666
  const incomingText = msg.ttsText ?? msg.speechText ?? msg.text;
667
  if (incomingText !== undefined) {{
 
668
  window._aikoLatestTtsText = incomingText;
669
  setSpeechText(incomingText, msg.duration ?? msg.audioDuration ?? null);
670
  if (msg.speaking === undefined && msg.playNow) setSpeaking(true);
671
  }}
 
 
 
 
 
672
  if (msg.viseme !== undefined) {{
673
  setMouth(msg.weight ?? 1.0, msg.viseme);
674
  clearTimeout(window._aikoMouthTimer);
675
  window._aikoMouthTimer = setTimeout(clearMouth, 180);
676
  }}
 
677
  }} catch (_) {{}}
678
  }});
 
 
679
  const loader = new GLTFLoader();
680
  loader.register(parser => new VRMLoaderPlugin(parser));
 
681
  function loadVrm(index = 0) {{
682
  const url = VRM_URLS[index];
683
  if (!url) {{
@@ -692,13 +714,14 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
692
  vrm.scene.traverse(o => {{ if (o.frustumCulled) o.frustumCulled = false; }});
693
  vrm.scene.rotation.y = 0;
694
  scene.add(vrm.scene);
695
- // Add this after scene.add:
696
  setTimeout(() => {{
697
  const lUA = vrm.humanoid?.getRawBoneNode('leftUpperArm');
698
  const rUA = vrm.humanoid?.getRawBoneNode('rightUpperArm');
699
  console.log('[aiko-vrm] leftUpperArm rotation:', lUA?.rotation);
700
  console.log('[aiko-vrm] rightUpperArm rotation:', rUA?.rotation);
701
  }}, 500);
 
702
  setExpression('relaxed', 0.25);
703
  console.log('Available expressions:', expressionNames());
704
  document.getElementById('loader').classList.add('fade');
@@ -708,18 +731,23 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
708
  loadVrm(index + 1);
709
  }});
710
  }}
 
711
  loadVrm();
 
 
712
  function tick() {{
713
  requestAnimationFrame(tick);
714
  resize();
715
- const dt = Math.min(clock.getDelta(), 0.05);
716
  controls.update();
717
  if (vrm) vrm.update(dt);
718
  applyIdle(dt);
719
  applyGestures(dt);
720
- const now = performance.now();
721
- const textMouth = speaking ? currentTextMouth(now) : null;
722
- const audioMouth = speaking ? getAudioMouth() : null;
 
 
723
  if (audioMouth !== null) {{
724
  setMouth(audioMouth, textMouth?.viseme ?? 'aa');
725
  }} else if (textMouth) {{
@@ -731,6 +759,7 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
731
  smoothedAudioMouth = 0;
732
  clearMouth();
733
  }}
 
734
  applyBlink(dt);
735
  renderer.render(scene, camera);
736
  }}
@@ -743,4 +772,4 @@ def avatar_html(vrm_urls: str | list[str]) -> str:
743
  '<iframe id="aiko-vrm-frame" title="Aiko VRM Avatar" '
744
  'sandbox="allow-scripts allow-same-origin" '
745
  f'srcdoc="{html.escape(srcdoc, quote=True)}"></iframe>'
746
- )
 
31
 
32
  def avatar_html(vrm_urls: str | list[str]) -> str:
33
  """Return an iframe containing the Three/VRM viewer.
34
+
35
  Camera is framed to a half-body shot (waist-up). The iframe exposes a
36
+ postMessage API for expression, viseme, ttsText, and duration control.
37
+ Caption bar at the bottom streams the speech text during audio playback.
38
+
39
+ postMessage API (JSON string or object):
40
+ { expression: str, intensity?: float } — set face expression
41
+ { ttsText: str, duration?: float } — set lip-sync + caption text
42
+ { speaking: bool } — force speaking state
43
+ { viseme: str, weight?: float } — direct viseme override
44
  """
45
  if isinstance(vrm_urls, str):
46
  vrm_urls = [vrm_urls]
 
97
  position: fixed;
98
  bottom: 0;
99
  left: 0;
100
+ right: 40%;
101
  min-height: 50px;
102
  max-height: 108px;
103
  padding: 10px 20px 14px 18px;
 
156
  import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js';
157
  import {{ VRMLoaderPlugin, VRMUtils }} from '@pixiv/three-vrm';
158
 
159
+ // ── Hard-lock this iframe's document height ──────────────────────────────
160
  document.documentElement.style.setProperty('height', '100vh', 'important');
161
  document.documentElement.style.setProperty('overflow', 'hidden', 'important');
162
  document.body.style.setProperty('height', '100vh', 'important');
163
  document.body.style.setProperty('overflow', 'hidden', 'important');
164
  document.body.style.setProperty('max-height', '100vh', 'important');
165
+
166
+ const RAW_VRM_URLS = {vrm_urls!r};
167
+ const VISEME_MAP = {{ A: 'aa', I: 'ih', U: 'ou', E: 'ee', O: 'oh' }};
168
  const VISEME_PRESETS = ['aa', 'ih', 'ou', 'ee', 'oh'];
169
  const TEXT_VISEME_MAP = {{
170
  a: 'aa', á: 'aa', à: 'aa', â: 'aa', ä: 'aa', あ: 'aa', ア: 'aa', か: 'aa', カ: 'aa', さ: 'aa', サ: 'aa', た: 'aa', タ: 'aa', な: 'aa', ナ: 'aa', は: 'aa', ハ: 'aa', ま: 'aa', マ: 'aa', や: 'aa', ヤ: 'aa', ら: 'aa', ラ: 'aa', わ: 'aa', ワ: 'aa',
 
173
  e: 'ee', é: 'ee', è: 'ee', ê: 'ee', ë: 'ee', え: 'ee', エ: 'ee', け: 'ee', ケ: 'ee', せ: 'ee', セ: 'ee', て: 'ee', テ: 'ee', ね: 'ee', ネ: 'ee', へ: 'ee', ヘ: 'ee', め: 'ee', メ: 'ee', れ: 'ee', レ: 'ee',
174
  o: 'oh', ó: 'oh', ò: 'oh', ô: 'oh', ö: 'oh', お: 'oh', オ: 'oh', こ: 'oh', コ: 'oh', そ: 'oh', ソ: 'oh', と: 'oh', ト: 'oh', の: 'oh', ノ: 'oh', ほ: 'oh', ホ: 'oh', も: 'oh', モ: 'oh', よ: 'oh', ヨ: 'oh', ろ: 'oh', ロ: 'oh', を: 'oh', ヲ: 'oh', ん: 'oh', ン: 'oh',
175
  }};
176
+
177
  function withTrailingSlash(url) {{ return url.endsWith('/') ? url : url + '/'; }}
178
  function buildVrmUrls(rawUrls) {{
179
  const urls = [];
 
192
  }}
193
  return [...new Set(urls)];
194
  }}
195
+
196
  const VRM_URLS = buildVrmUrls(RAW_VRM_URLS);
197
+ const canvas = document.getElementById('canvas');
198
  const renderer = new THREE.WebGLRenderer({{ canvas, antialias: true, alpha: true }});
199
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
200
  renderer.outputColorSpace = THREE.SRGBColorSpace;
201
+
202
  const scene = new THREE.Scene();
 
 
 
203
  const camera = new THREE.PerspectiveCamera(22, 1, 0.1, 100);
204
  camera.position.set(0.30, 1.18, 2.1);
205
  const controls = new OrbitControls(camera, canvas);
 
208
  controls.enablePan = false;
209
  controls.minDistance = 1.0;
210
  controls.maxDistance = 3.2;
211
+
212
  scene.add(new THREE.HemisphereLight(0xded4ff, 0x21182f, 2.4));
213
  const key = new THREE.DirectionalLight(0xffffff, 2.7);
214
  key.position.set(1.8, 3.0, 2.5);
 
216
  const rim = new THREE.DirectionalLight(0x9b7cff, 1.5);
217
  rim.position.set(-2.5, 1.4, -1.2);
218
  scene.add(rim);
219
+
 
220
  let vrm = null;
221
  let mouth = 0;
222
  let smoothedAudioMouth = 0;
 
234
  let speechVisemes = [];
235
  let speechStartedAt = 0;
236
  let speechDuration = 0;
237
+
238
  const REST = window._REST = {{
239
  leftUpperArm: {{ x: 0.02, y: 0.0, z: -1.28 }},
240
  rightUpperArm: {{ x: 0.02, y: 0.0, z: 1.28 }},
 
243
  leftHand: {{ x: 0.0, y: 0.08, z: 0.0 }},
244
  rightHand: {{ x: 0.0, y:-0.08, z: 0.0 }},
245
  }};
246
+
247
  const dot = document.getElementById('dot');
248
  const statusText = document.getElementById('status-text');
249
  const emotionEl = document.getElementById('emotion');
250
  const captionBar = document.getElementById('caption-bar');
251
  const captionText = document.getElementById('caption-text');
252
+
253
  // ── Caption streaming ─────────────────────────────────────────────────────
254
+ // Paces word-by-word reveal proportional to audio duration.
255
  let captionWords = [];
256
  let captionIdx = 0;
257
  let captionTimer = null;
258
+
259
  function startCaption(text) {{
260
  clearInterval(captionTimer);
261
  captionWords = text.trim().split(/\s+/).filter(Boolean);
 
263
  captionText.textContent = '';
264
  captionBar.classList.remove('hidden');
265
  if (!captionWords.length) return;
266
+
267
  const totalWords = captionWords.length;
268
+ const totalSeconds = (lastAudio && lastAudio.duration > 0)
269
+ ? lastAudio.duration
270
+ : Math.max(2, totalWords * 0.38);
271
+ const msPerWord = (totalSeconds * 1000) / totalWords;
272
+
273
  captionTimer = setInterval(() => {{
274
  if (captionIdx >= captionWords.length) {{
275
  clearInterval(captionTimer);
 
276
  setTimeout(() => {{ captionBar.classList.add('hidden'); }}, 1800);
277
  return;
278
  }}
 
279
  const windowEnd = captionIdx + 1;
280
  const windowStart = Math.max(0, windowEnd - 12);
281
  captionText.textContent = captionWords.slice(windowStart, windowEnd).join(' ');
282
  captionIdx++;
283
  }}, msPerWord);
284
  }}
285
+
286
  function stopCaption() {{
287
  clearInterval(captionTimer);
288
  setTimeout(() => captionBar.classList.add('hidden'), 1200);
289
  }}
290
+
291
  // ─────────────────────────────────────────────────────────────────────────
292
  function nextBlinkWait() {{ return 3.0 + Math.random() * 4.0; }}
293
  function expressionNames() {{
 
298
  function safeSetExpression(name, weight) {{
299
  try {{ vrm?.expressionManager?.setValue(name, weight); }} catch (_) {{}}
300
  }}
301
+
302
  let lastW = 0, lastH = 0;
303
  function resize() {{
304
+ const w = Math.max(1, Math.min(window.screen.width, canvas.clientWidth || window.innerWidth));
305
+ const h = Math.max(1, Math.min(window.screen.height, canvas.clientHeight || window.innerHeight));
306
+ if (w === lastW && h === lastH) return;
307
+ lastW = w; lastH = h;
308
+ renderer.setSize(w, h, false);
309
+ camera.aspect = w / h;
310
+ camera.updateProjectionMatrix();
311
  }}
312
  addEventListener('resize', resize);
313
+
314
  function setExpression(name, weight = 1) {{
315
  if (!vrm?.expressionManager) return;
316
  for (const k of ['happy', 'relaxed', 'angry', 'sad', 'surprised']) {{
 
318
  }}
319
  emotionEl.textContent = name || 'neutral';
320
  }}
321
+
322
  function setMouth(weight, viseme = 'aa') {{
323
  if (!vrm) return;
324
  const clamped = Math.max(0, Math.min(1, Number(weight) || 0));
 
343
  if (jaw) jaw.rotation.x = clamped * 0.5;
344
  }}
345
  }}
346
+
347
  function clearMouth() {{ setMouth(0, 'aa'); }}
348
+
349
  function setSpeaking(active) {{
350
  speaking = Boolean(active);
351
  dot.className = speaking ? 'speaking' : '';
 
353
  setExpression(speaking ? 'happy' : 'relaxed', speaking ? 0.55 : 0.25);
354
  if (!speaking) {{ clearMouth(); stopCaption(); }}
355
  }}
356
+
357
  function estimateSpeechDuration(text, requestedDuration = null) {{
358
  const explicit = Number(requestedDuration);
359
  if (Number.isFinite(explicit) && explicit > 0) return explicit;
360
  if (lastAudio && Number.isFinite(lastAudio.duration) && lastAudio.duration > 0) return lastAudio.duration;
361
  return Math.max(1.0, Math.min(12, text.length * 0.075));
362
  }}
363
+
364
  function textToVisemes(text) {{
365
  const tokens = [];
366
  let lastViseme = 'aa';
 
377
  }}
378
  return tokens.length ? tokens : [{{ viseme: 'aa', weight: 0.25 }}];
379
  }}
380
+
381
  function setSpeechText(text, duration = null) {{
382
  const nextText = String(text || '').trim();
383
  if (!nextText) return;
 
385
  speechVisemes = textToVisemes(speechText);
386
  speechDuration = estimateSpeechDuration(speechText, duration);
387
  speechStartedAt = performance.now();
 
388
  startCaption(speechText);
389
  }}
390
+
391
  function currentTextMouth(now) {{
392
  if (!speechVisemes.length) return null;
393
+ const audioDuration = lastAudio && Number.isFinite(lastAudio.duration) && lastAudio.duration > 0
394
+ ? lastAudio.duration : speechDuration;
395
  const duration = Math.max(0.25, audioDuration || speechDuration || 1);
396
+ const elapsed = lastAudio && !lastAudio.paused
397
+ ? lastAudio.currentTime
398
+ : (now - speechStartedAt) / 1000;
399
  const progress = Math.max(0, Math.min(0.999, elapsed / duration));
400
  const index = Math.min(speechVisemes.length - 1, Math.floor(progress * speechVisemes.length));
401
  const token = speechVisemes[index];
 
405
  weight: Math.max(0, Math.min(1, token.weight * (0.55 + Math.abs(syllablePhase) * 0.45))),
406
  }};
407
  }}
408
+
 
 
 
 
 
 
 
 
 
 
 
409
  function getBone(name) {{
410
  const h = vrm?.humanoid;
411
  if (!h) return null;
412
  return h.getRawBoneNode?.(name) || h.getNormalizedBoneNode?.(name) || null;
413
  }}
414
+
415
  function applyIdle(dt) {{
416
  if (!vrm?.humanoid) return;
417
  idleTime += dt;
 
448
  if (rUA) {{ rUA.rotation.x = REST.rightUpperArm.x + Math.sin(idleTime * 0.53 + 0.9) * 0.010; rUA.rotation.y = REST.rightUpperArm.y + Math.sin(idleTime * 0.35 + 0.4) * 0.006; rUA.rotation.z = REST.rightUpperArm.z + Math.sin(idleTime * 0.37 + 0.7) * 0.008; }}
449
  if (lLA) {{ lLA.rotation.x = REST.leftLowerArm.x + Math.sin(idleTime * 0.61) * 0.008; lLA.rotation.y = REST.leftLowerArm.y; lLA.rotation.z = REST.leftLowerArm.z + Math.sin(idleTime * 0.43) * 0.004; }}
450
  if (rLA) {{ rLA.rotation.x = REST.rightLowerArm.x + Math.sin(idleTime * 0.57 + 1.4) * 0.008; rLA.rotation.y = REST.rightLowerArm.y; rLA.rotation.z = REST.rightLowerArm.z + Math.sin(idleTime * 0.51 + 0.5) * 0.004; }}
451
+ if (lH) {{ lH.rotation.x = REST.leftHand.x; lH.rotation.y = REST.leftHand.y + Math.sin(idleTime * 0.33) * 0.008; lH.rotation.z = REST.leftHand.z; }}
452
+ if (rH) {{ rH.rotation.x = REST.rightHand.x; rH.rotation.y = REST.rightHand.y + Math.sin(idleTime * 0.29 + 1.2) * 0.008; rH.rotation.z = REST.rightHand.z; }}
453
  }}
454
+
455
+ // ── Idle gesture state machine ───────────────��────────────────────────────
456
  let gestureState = 'none';
457
  let gestureT = 0;
458
  let gestureDuration = 0;
 
460
  let gestureTarget = null;
461
  const GESTURES = ['lookAround', 'sideGlance', 'meetGaze', 'curiousTilt', 'shiftWeight', 'hairTuck', 'stretchNeck'];
462
  const GESTURE_DURATION = {{
463
+ lookAround: 3.2, sideGlance: 2.2, meetGaze: 2.8,
464
+ curiousTilt: 2.4, shiftWeight: 3.2, hairTuck: 2.7, stretchNeck: 2.6,
 
 
 
 
 
465
  }};
466
+
467
  function pickGesture() {{
468
  const g = GESTURES[Math.floor(Math.random() * GESTURES.length)];
469
  gestureState = g;
 
477
  hairSide: side,
478
  }};
479
  }}
480
+
481
  function easeInOutSine(t) {{ return -(Math.cos(Math.PI * t) - 1) / 2; }}
482
  function holdCurve(progress, inPortion = 0.28, outPortion = 0.30) {{
483
  if (progress < inPortion) return easeInOutSine(progress / inPortion);
484
  if (progress > 1 - outPortion) return easeInOutSine((1 - progress) / outPortion);
485
  return 1;
486
  }}
487
+
488
  function applyGestures(dt) {{
489
  if (!vrm?.humanoid) return;
490
  if (speaking) {{ gestureState = 'none'; return; }}
 
504
  const spine = getBone('spine'), hips = getBone('hips');
505
  switch (gestureState) {{
506
  case 'lookAround':
507
+ if (head) {{ head.rotation.y += gestureTarget.lookAround * held; head.rotation.x += Math.sin(eased * Math.PI) * 0.025; }}
508
+ if (neck) neck.rotation.y += gestureTarget.lookAround * 0.22 * held;
 
 
 
509
  break;
510
  case 'sideGlance':
511
+ if (head) {{ head.rotation.y += gestureTarget.sideGlance * held; head.rotation.z -= gestureTarget.sideGlance * 0.18 * intensity; }}
 
 
 
512
  safeSetExpression('relaxed', 0.28);
513
  break;
514
  case 'meetGaze':
515
+ if (head) {{ head.rotation.y *= 1 - held * 0.78; head.rotation.z *= 1 - held * 0.70; head.rotation.x += held * 0.018; }}
516
+ if (neck) {{ neck.rotation.y *= 1 - held * 0.55; neck.rotation.z *= 1 - held * 0.55; }}
 
 
 
 
 
 
 
517
  safeSetExpression('relaxed', 0.30);
518
  safeSetExpression('happy', 0.04 * held);
519
  break;
520
  case 'curiousTilt':
521
+ if (head) {{ head.rotation.z += gestureTarget.curiousTilt * held; head.rotation.x -= 0.018 * intensity; }}
522
+ if (neck) neck.rotation.z += gestureTarget.curiousTilt * 0.45 * held;
 
 
 
523
  break;
524
  case 'shiftWeight':
525
+ if (hips) hips.position.x += Math.sin(eased * Math.PI) * 0.016;
526
  if (spine) spine.rotation.z += Math.sin(eased * Math.PI) * 0.018;
527
+ if (head) head.rotation.z -= Math.sin(eased * Math.PI) * 0.012;
528
  break;
529
  case 'hairTuck':
530
  if (gestureTarget.hairSide < 0) {{
 
536
  if (rLA) {{ rLA.rotation.x = REST.rightLowerArm.x - intensity * 0.26; rLA.rotation.z = REST.rightLowerArm.z + intensity * 0.10; }}
537
  if (rH) {{ rH.rotation.y = REST.rightHand.y - intensity * 0.16; rH.rotation.z = REST.rightHand.z + intensity * 0.10; }}
538
  }}
539
+ if (head) {{ head.rotation.z -= gestureTarget.hairSide * intensity * 0.035; head.rotation.y += gestureTarget.hairSide * intensity * 0.025; }}
 
 
 
540
  break;
541
  case 'stretchNeck':
542
  if (neck) neck.rotation.x += -intensity * 0.05;
 
545
  }}
546
  if (progress >= 1) gestureState = 'none';
547
  }}
548
+
549
  function applyBlink(dt) {{
550
  if (!vrm?.expressionManager) return;
551
  if (blinkPhase === 'wait') {{
 
564
  }}
565
  }}
566
  }}
567
+
568
  let lastAudio = null;
569
  let audioContext = null;
570
  let analyserAudio = null;
 
572
  let audioSource = null;
573
  let audioMeterOk = false;
574
  let meteredAudio = null;
575
+
576
  function setupAudioMeter(audio) {{
577
+ if (!audio || (audioMeterOk && audio === meteredAudio)) return;
578
  try {{
579
  audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)();
580
  if (audioContext.state === 'suspended') audioContext.resume().catch(() => {{}});
581
  analyserAudio = audioContext.createAnalyser();
582
  analyserAudio.fftSize = 512;
583
  analyserAudio.smoothingTimeConstant = 0.72;
584
+ audioData = new Uint8Array(analyserAudio.fftSize);
585
  audioSource = audio._aikoMediaSource || audioContext.createMediaElementSource(audio);
586
  audio._aikoMediaSource = audioSource;
587
  audioSource.connect(analyserAudio);
 
596
  audioData = null;
597
  }}
598
  }}
599
+
600
  function getAudioMouth() {{
601
  if (!analyserAudio || !audioData) return null;
602
  analyserAudio.getByteTimeDomainData(audioData);
 
605
  const centered = (audioData[i] - 128) / 128;
606
  sum += centered * centered;
607
  }}
608
+ const rms = Math.sqrt(sum / audioData.length);
609
+ const gated = Math.max(0, rms - 0.018);
610
  const target = Math.min(1, Math.pow(gated * 7.5, 0.72));
611
  smoothedAudioMouth += (target - smoothedAudioMouth) * (target > smoothedAudioMouth ? 0.55 : 0.28);
612
  return smoothedAudioMouth;
613
  }}
614
+
615
  function findParentAudio() {{
616
  try {{ return parent.document.querySelector('#aiko-audio audio') || parent.document.querySelector('audio'); }}
617
  catch (_) {{ return null; }}
618
  }}
619
+
620
  function syncAudioState(audio) {{
621
  if (!audio) return;
622
  setSpeaking(!audio.paused && !audio.ended && audio.currentTime >= 0);
623
  }}
624
+
625
  function attachAudio(audio) {{
626
  if (!audio) return;
627
  if (audio !== lastAudio) {{
628
  lastAudio = audio;
629
+
630
  audio.addEventListener('play', () => {{
631
  setSpeaking(true);
632
  setupAudioMeter(audio);
633
+ // ttsText is pushed via postMessage from the parent page's JS bridge;
634
+ // _aikoLatestTtsText is set on window by that same bridge as a fallback.
635
+ if (window._aikoLatestTtsText) {{
636
+ setSpeechText(window._aikoLatestTtsText, audio.duration);
637
+ window._aikoLatestTtsText = '';
638
+ }}
 
 
 
 
639
  }});
640
+
641
  audio.addEventListener('playing', () => {{
642
  setSpeaking(true);
643
  setupAudioMeter(audio);
644
+ if (window._aikoLatestTtsText) {{
645
+ setSpeechText(window._aikoLatestTtsText, audio.duration);
646
+ window._aikoLatestTtsText = '';
647
+ }}
648
+ }});
649
+
650
+ audio.addEventListener('timeupdate', () => {{
651
+ if (!audio.paused && audio.currentTime > 0) setSpeaking(true);
652
  }});
 
653
  audio.addEventListener('pause', () => setSpeaking(false));
654
  audio.addEventListener('ended', () => setSpeaking(false));
655
  }}
656
  syncAudioState(audio);
657
  }}
658
+
659
  setInterval(() => attachAudio(findParentAudio()), 500);
660
+
661
+ // ── postMessage API ───────────────────────────────────────────────────────
662
+ // Receives messages from app.py's tts_text.change JS bridge (and anything
663
+ // else that wants to control the avatar).
664
  window.addEventListener('message', (e) => {{
665
  try {{
666
  const msg = (typeof e.data === 'string') ? JSON.parse(e.data) : e.data;
667
+
668
+ // Expression / emotion
669
  if (msg.expression !== undefined) {{
670
  setExpression(msg.expression, msg.intensity ?? 1.0);
671
  clearTimeout(exprResetTimer);
672
+ if (msg.expression && msg.expression !== 'neutral') {{
673
  exprResetTimer = setTimeout(() => setExpression('relaxed', 0.25), EXPR_RESET_DELAY);
674
+ }}
675
  }}
676
+
677
+ // Speech text for lip-sync + caption (sent by the JS bridge alongside audio)
678
  const incomingText = msg.ttsText ?? msg.speechText ?? msg.text;
679
  if (incomingText !== undefined) {{
680
+ // Store for the audio 'play' event to pick up if it fires after postMessage
681
  window._aikoLatestTtsText = incomingText;
682
  setSpeechText(incomingText, msg.duration ?? msg.audioDuration ?? null);
683
  if (msg.speaking === undefined && msg.playNow) setSpeaking(true);
684
  }}
685
+
686
+ // Direct speaking state override
687
+ if (msg.speaking !== undefined) setSpeaking(msg.speaking);
688
+
689
+ // Direct viseme override (for external controllers)
690
  if (msg.viseme !== undefined) {{
691
  setMouth(msg.weight ?? 1.0, msg.viseme);
692
  clearTimeout(window._aikoMouthTimer);
693
  window._aikoMouthTimer = setTimeout(clearMouth, 180);
694
  }}
695
+
696
  }} catch (_) {{}}
697
  }});
698
+
699
+ // ── VRM loader ────────────────────────────────────────────────────────────
700
  const loader = new GLTFLoader();
701
  loader.register(parser => new VRMLoaderPlugin(parser));
702
+
703
  function loadVrm(index = 0) {{
704
  const url = VRM_URLS[index];
705
  if (!url) {{
 
714
  vrm.scene.traverse(o => {{ if (o.frustumCulled) o.frustumCulled = false; }});
715
  vrm.scene.rotation.y = 0;
716
  scene.add(vrm.scene);
717
+
718
  setTimeout(() => {{
719
  const lUA = vrm.humanoid?.getRawBoneNode('leftUpperArm');
720
  const rUA = vrm.humanoid?.getRawBoneNode('rightUpperArm');
721
  console.log('[aiko-vrm] leftUpperArm rotation:', lUA?.rotation);
722
  console.log('[aiko-vrm] rightUpperArm rotation:', rUA?.rotation);
723
  }}, 500);
724
+
725
  setExpression('relaxed', 0.25);
726
  console.log('Available expressions:', expressionNames());
727
  document.getElementById('loader').classList.add('fade');
 
731
  loadVrm(index + 1);
732
  }});
733
  }}
734
+
735
  loadVrm();
736
+
737
+ // ── Render loop ───────────────────────────────────────────────────────────
738
  function tick() {{
739
  requestAnimationFrame(tick);
740
  resize();
741
+ const dt = Math.min(clock.getDelta(), 0.05);
742
  controls.update();
743
  if (vrm) vrm.update(dt);
744
  applyIdle(dt);
745
  applyGestures(dt);
746
+
747
+ const now = performance.now();
748
+ const textMouth = speaking ? currentTextMouth(now) : null;
749
+ const audioMouth = speaking ? getAudioMouth() : null;
750
+
751
  if (audioMouth !== null) {{
752
  setMouth(audioMouth, textMouth?.viseme ?? 'aa');
753
  }} else if (textMouth) {{
 
759
  smoothedAudioMouth = 0;
760
  clearMouth();
761
  }}
762
+
763
  applyBlink(dt);
764
  renderer.render(scene, camera);
765
  }}
 
772
  '<iframe id="aiko-vrm-frame" title="Aiko VRM Avatar" '
773
  'sandbox="allow-scripts allow-same-origin" '
774
  f'srcdoc="{html.escape(srcdoc, quote=True)}"></iframe>'
775
+ )