refactor: update LLM warmup, optimize audio handling by removing conversion, and refine VRM animation API
Browse files- .gitignore +5 -0
- app.py +170 -140
- core/think.py +5 -2
- ui/speak.py +5 -15
- 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 |
-
#
|
| 83 |
# ─────────────────────────────────────────────
|
| 84 |
-
_DONE = object() # sentinel
|
| 85 |
|
| 86 |
-
|
| 87 |
-
def _stream_response(message: str, history: list):
|
| 88 |
"""
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
"""
|
| 96 |
history = list(history) + [
|
| 97 |
-
{"role": "user",
|
| 98 |
{"role": "assistant", "content": "▋"},
|
| 99 |
]
|
|
|
|
| 100 |
yield history, None, None
|
| 101 |
|
| 102 |
-
# ── Stage 1: LLM
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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 |
-
|
| 205 |
-
time.sleep(0.05)
|
| 206 |
|
| 207 |
-
|
| 208 |
-
if final_text[0] and history[-1]["content"] != final_text[0]:
|
| 209 |
-
history[-1]["content"] = final_text[0]
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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":
|
| 98 |
-
"messages": [{"role": "user", "content": "
|
|
|
|
| 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
|
| 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 |
-
#
|
| 146 |
-
|
| 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
|
| 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}.
|
| 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 directly — no 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
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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%;
|
| 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
|
| 161 |
-
const VISEME_MAP
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 257 |
const totalWords = captionWords.length;
|
| 258 |
-
const totalSeconds = (lastAudio && lastAudio.duration > 0)
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 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
|
|
|
|
| 373 |
const duration = Math.max(0.25, audioDuration || speechDuration || 1);
|
| 374 |
-
const elapsed = lastAudio && !lastAudio.paused
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 438 |
-
if (rH) {{ rH.rotation.x
|
| 439 |
}}
|
| 440 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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)
|
| 529 |
if (spine) spine.rotation.z += Math.sin(eased * Math.PI) * 0.018;
|
| 530 |
-
if (head)
|
| 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
|
| 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
|
| 612 |
-
const gated
|
| 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 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
}} else if (++tries >= 10) {{
|
| 639 |
-
clearInterval(poll);
|
| 640 |
-
}}
|
| 641 |
-
}}, 200);
|
| 642 |
}});
|
|
|
|
| 643 |
audio.addEventListener('playing', () => {{
|
| 644 |
setSpeaking(true);
|
| 645 |
setupAudioMeter(audio);
|
| 646 |
-
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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
|
| 716 |
controls.update();
|
| 717 |
if (vrm) vrm.update(dt);
|
| 718 |
applyIdle(dt);
|
| 719 |
applyGestures(dt);
|
| 720 |
-
|
| 721 |
-
const
|
| 722 |
-
const
|
|
|
|
|
|
|
| 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 |
+
)
|