| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { TalkingHead } from "@met4citizen/talkinghead"; |
| import { HeadAudio } from "./vendor/headaudio.min.mjs"; |
|
|
| const HEADAUDIO_WORKLET_URL = "/vendor/headworklet.min.mjs"; |
| const HEADAUDIO_MODEL_URL = "/vendor/model-en-mixed.bin"; |
|
|
| export const AVATAR_MOODS = [ |
| "neutral", |
| "happy", |
| "angry", |
| "sad", |
| "fear", |
| "disgust", |
| "love", |
| "sleep", |
| ]; |
|
|
| export const AVATAR_GESTURES = [ |
| "handup", |
| "index", |
| "ok", |
| "thumbup", |
| "thumbdown", |
| "side", |
| "shrug", |
| ]; |
|
|
| export class AvatarStage { |
| |
| constructor(container) { |
| this._container = container; |
| |
| this.head = null; |
| |
| this._headaudio = null; |
| this._lastSpeechEnded = 0; |
| } |
|
|
| |
| |
| |
| |
| async init(opt = {}) { |
| |
| |
| |
| this.head = new TalkingHead(this._container, { |
| ttsEndpoint: "N/A", |
| lipsyncModules: [], |
| |
| |
| |
| |
| cameraView: "upper", |
| cameraDistance: -1.4, |
| cameraY: -0.15, |
| cameraX: -0.18, |
| cameraRotateEnable: false, |
| lightAmbientIntensity: 0, |
| lightDirectIntensity: 0, |
| lightSpotIntensity: 0, |
| |
| |
| avatarIdleEyeContact: 0.3, |
| avatarSpeakingEyeContact: 0.7, |
| }); |
|
|
| await this.head.showAvatar( |
| { |
| url: opt.avatarUrl ?? "/avatars/brunette.glb", |
| body: opt.body ?? "F", |
| avatarMood: "neutral", |
| }, |
| opt.onprogress ?? null, |
| ); |
|
|
| await this._initLipsync(); |
| } |
|
|
| async _initLipsync() { |
| const head = (this.head); |
| await head.audioCtx.audioWorklet.addModule(HEADAUDIO_WORKLET_URL); |
| const headaudio = new HeadAudio(head.audioCtx); |
| await headaudio.loadModel(HEADAUDIO_MODEL_URL); |
|
|
| |
| |
| head.audioSpeechGainNode.connect(headaudio); |
|
|
| |
| headaudio.onvalue = (key, value) => { |
| const mt = head.mtAvatar?.[key]; |
| if (mt) Object.assign(mt, { newvalue: value, needsUpdate: true }); |
| }; |
| head.opt.update = headaudio.update.bind(headaudio); |
|
|
| |
| |
| headaudio.onended = () => { |
| this._lastSpeechEnded = Date.now(); |
| }; |
| headaudio.onstarted = () => { |
| if (Date.now() - this._lastSpeechEnded > 150) { |
| head.lookAtCamera(500); |
| head.speakWithHands(); |
| } |
| }; |
|
|
| this._headaudio = headaudio; |
| } |
|
|
| |
| get audioCtx() { |
| return this.head?.audioCtx ?? null; |
| } |
|
|
| |
| get voiceSink() { |
| return this.head?.audioAnalyzerNode ?? null; |
| } |
|
|
| |
| resume() { |
| this.head?.start(); |
| if (this.head && this.head.audioCtx.state === "suspended") { |
| this.head.audioCtx.resume().catch(() => {}); |
| } |
| } |
|
|
| |
| |
| |
| |
| setConversationState(status) { |
| const head = this.head; |
| if (!head) return; |
| switch (status) { |
| case "user-speaking": |
| |
| |
| |
| head.isSpeaking = false; |
| head.lookAtCamera(800); |
| break; |
| case "ai-speaking": |
| head.isSpeaking = true; |
| break; |
| case "processing": |
| head.isSpeaking = false; |
| break; |
| case "closed": |
| case "error": |
| case "idle": |
| head.isSpeaking = false; |
| head.stopGesture(300); |
| break; |
| default: |
| head.isSpeaking = false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| runTool(name, args) { |
| const head = this.head; |
| if (!head) return null; |
| if (name === "set_mood") { |
| const mood = typeof args.mood === "string" ? args.mood : ""; |
| if (!AVATAR_MOODS.includes(mood)) return `Unknown mood: ${mood}`; |
| head.setMood(mood); |
| return `Mood set to ${mood}.`; |
| } |
| if (name === "make_hand_gesture") { |
| const gesture = typeof args.gesture === "string" ? args.gesture : ""; |
| if (!AVATAR_GESTURES.includes(gesture)) return `Unknown gesture: ${gesture}`; |
| head.playGesture(gesture, 3); |
| return `Playing gesture ${gesture}.`; |
| } |
| if (name === "make_facial_expression") { |
| const emoji = typeof args.emoji === "string" ? args.emoji.trim() : ""; |
| if (!emoji) return "No emoji given."; |
| head.speakEmoji(emoji); |
| return `Expressing ${emoji}.`; |
| } |
| return null; |
| } |
| } |