gemma-avatar / src /avatar.js
bep40's picture
Upload src/avatar.js
33ab50b verified
Raw
History Blame Contribute Delete
15 kB
// @ts-check
/**
* The avatar stage: a TalkingHead 3D avatar with real-time audio-driven
* lip-sync (HeadAudio), plus static model fallback for custom GLBs.
*
* GLB ARCHITECTURE (avaturn.me exports):
* Type A (With morph targets) — ~13MB, has Head_Mesh with 72 ARKit
* viseme blend shapes, separate Eye/Mouth/Tongue meshes, Mixamo skeleton.
* → Load via TalkingHead.showAvatar() for lip-sync + expressions.
*
* Type B (Without morph targets) — ~4MB, single avaturn_look_0 block,
* no blend shapes at all. showAvatar() throws 'Blend shapes not found'.
* → Load via THREE.GLTFLoader directly into TalkingHead's scene.
*
* CLASSIFICATION: Pre-fetch GLB as ArrayBuffer, parse with GLTFLoader,
* inspect for morphTargetInfluences. One network call, zero double-load.
*/
import { TalkingHead } from "@met4citizen/talkinghead";
import { HeadAudio } from "./vendor/headaudio.min.mjs";
import { Box3, Vector3, HemisphereLight, DirectionalLight } from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
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",
];
const HEAD_CONFIG = {
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,
};
/** Fully dispose all Three.js + AudioContext resources. */
function _fullyDispose(head) {
try {
if (head.scene) {
head.scene.traverse((obj) => {
if (obj.isMesh) {
obj.geometry?.dispose();
if (obj.material) {
(Array.isArray(obj.material) ? obj.material : [obj.material]).forEach((m) => m.dispose());
}
}
});
}
while (head.scene?.children?.length) head.scene.remove(head.scene.children[0]);
if (head.renderer) {
head.renderer.dispose();
const canvas = head.renderer.domElement;
if (canvas?.parentNode) canvas.parentNode.removeChild(canvas);
}
if (head.audioCtx && head.audioCtx.state !== "closed") {
head.audioCtx.close().catch(() => {});
}
head.dispose?.();
} catch (e) {
console.warn("[AvatarStage] dispose:", e);
}
}
export class AvatarStage {
/** @param {HTMLElement} container */
constructor(container) {
this._container = container;
/** @type {TalkingHead | null} */
this.head = null;
/** @type {any | null} */
this._headaudio = null;
this._lastSpeechEnded = 0;
this._loadingEl = document.getElementById("loading");
this._lastError = null;
/** @type {boolean} True when model is static (no morph targets) */
this._staticModel = false;
/** @type {THREE.Object3D | null} */
this._modelRoot = null;
/** @type {THREE.Light[]} */
this._staticLights = [];
}
/**
* Initialize the avatar. Returns true on success, false on failure.
*
* Loading strategy:
* Default model (vuong.glb) — has morph targets → showAvatar() directly
* Custom models — pre-fetch + classify:
* Type A (has morphs) → Blob URL → showAvatar()
* Type B (no morphs) → add to scene from buffer directly, skip showAvatar()
*/
async init(opt = {}) {
// ── FULL DISPOSE ────────────────────────────────────────────────
if (this.head) {
this._removeStaticLights();
_fullyDispose(this.head);
this.head = null;
this._headaudio = null;
this._modelRoot = null;
this._staticModel = false;
}
while (this._container.firstChild) this._container.removeChild(this._container.firstChild);
this._lastError = null;
const isDefault = !opt.avatarUrl;
const avatarUrl = opt.avatarUrl ?? "/avatars/vuong.glb";
/** @type {boolean} */
let hasMorphs;
/** @type {ArrayBuffer | null} */
let buffer = null;
if (isDefault) {
// vuong.glb is known to have morph targets — skip fetch/parse
hasMorphs = true;
} else {
// Custom model: classify via fetch + parseAsync
try {
const result = await this._classifyGLB(avatarUrl);
hasMorphs = result.hasMorphs;
buffer = result.buffer;
} catch (err) {
console.warn("[AvatarStage] Classification failed, assuming morph:", err);
hasMorphs = true;
}
}
this.head = new TalkingHead(this._container, {
...HEAD_CONFIG,
cameraDistance: isDefault ? HEAD_CONFIG.cameraDistance : -1.25,
cameraY: isDefault ? HEAD_CONFIG.cameraY : 0.0,
cameraX: isDefault ? HEAD_CONFIG.cameraX : 0.0,
});
console.log("[AvatarStage] Loading:", avatarUrl, `(morphs=${hasMorphs})`);
if (hasMorphs) {
// ── Type A: Has morph targets → via showAvatar() for lip-sync ──
let morphOk = false;
try {
if (isDefault) {
await this._loadShowAvatar(avatarUrl, opt);
} else {
const blob = new Blob([/** @type {ArrayBuffer} */(buffer)], { type: "model/gltf-binary" });
const blobUrl = URL.createObjectURL(blob);
await this._loadShowAvatar(blobUrl, opt);
URL.revokeObjectURL(blobUrl);
}
morphOk = true;
console.log("[AvatarStage] Model loaded WITH morph targets");
} catch (err) {
console.error("[AvatarStage] showAvatar failed:", err?.message);
if (isDefault) {
this._lastError = err?.message || String(err);
_fullyDispose(this.head);
this.head = null;
return false;
}
}
if (morphOk) {
try { await this._initLipsync(); } catch (err) { console.warn("[AvatarStage] Lipsync:", err); }
} else if (buffer) {
try {
await this._loadStaticFromBuffer(buffer, opt);
this._staticModel = true;
this._addStaticLights();
} catch (err2) {
this._lastError = err2?.message || String(err2);
_fullyDispose(this.head);
this.head = null;
return false;
}
} else {
this._lastError = "showAvatar failed and no buffer available";
_fullyDispose(this.head);
this.head = null;
return false;
}
} else {
// ── Type B: No morph targets → load as static ─────────────────
try {
await this._loadStaticFromBuffer(/** @type {ArrayBuffer} */(buffer), opt);
this._staticModel = true;
this._addStaticLights();
console.log("[AvatarStage] Model loaded as STATIC (no morph targets)");
} catch (err) {
this._lastError = err?.message || String(err);
console.error("[AvatarStage] Static load failed:", this._lastError);
_fullyDispose(this.head);
this.head = null;
return false;
}
}
this._adjustCamera(avatarUrl);
return true;
}
/**
* Fetch a GLB and classify it: does it have morph targets or not?
* Returns { hasMorphs, buffer } — one fetch, one parse.
*/
async _classifyGLB(url) {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const loader = new GLTFLoader();
const gltf = await loader.parseAsync(buffer, "");
let hasMorphs = false;
gltf.scene.traverse((child) => {
if (child.isMesh && child.morphTargetInfluences) {
hasMorphs = child.morphTargetInfluences.length > 0;
}
if (!hasMorphs && child.isMesh && child.geometry && child.geometry.morphAttributes) {
const attrs = child.geometry.morphAttributes;
hasMorphs = !!(attrs.position && attrs.position.length > 0);
}
});
gltf.scene.traverse((child) => {
if (child.isMesh && child.geometry) child.geometry.dispose();
});
return { hasMorphs, buffer };
}
/** Load morph-target model via TalkingHead showAvatar */
async _loadShowAvatar(url, opt) {
const head = /** @type {TalkingHead} */ (this.head);
await head.showAvatar(
{ url, body: opt.body ?? "F", avatarMood: "neutral" },
(ev) => {
if (ev.lengthComputable) {
const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
if (this._loadingEl) this._loadingEl.textContent = `Loading avatar ${pct}%`;
}
opt.onprogress?.(ev);
},
);
}
/** Load GLB as a static model via THREE.GLTFLoader from pre-fetched ArrayBuffer.
* Only called for Type B models (no morph targets).
* Uses the buffer already fetched during classification — zero extra network calls. */
_loadStaticFromBuffer(buffer, opt) {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
const head = /** @type {TalkingHead} */ (this.head);
loader.parse(
buffer,
"",
(gltf) => {
const model = gltf.scene;
this._modelRoot = model;
for (let i = head.scene.children.length - 1; i >= 0; i--) {
const c = head.scene.children[i];
if (c !== head.camera) head.scene.remove(c);
}
model.visible = true;
head.scene.add(model);
if (this._loadingEl) this._loadingEl.textContent = "Positioning model...";
opt?.onprogress?.({ lengthComputable: true, loaded: 1, total: 1 });
resolve();
},
(err) => reject(new Error(`GLTF parse: ${err?.message || err}`)),
);
});
}
/** Add lighting for static models (TalkingHead lights are disabled at intensity 0) */
_addStaticLights() {
this._removeStaticLights();
const head = /** @type {TalkingHead} */ (this.head);
const lights = [
new HemisphereLight(0xffffff, 0x444444, 0.7),
new DirectionalLight(0xffffff, 0.9),
];
lights[1].position.set(5, 8, 5);
const fill = new DirectionalLight(0x8888ff, 0.3);
fill.position.set(-3, 2, -4);
lights.push(fill);
for (const l of lights) { head.scene.add(l); this._staticLights.push(l); }
console.log("[AvatarStage] Added", lights.length, "lights for static model");
}
_removeStaticLights() {
for (const l of this._staticLights) {
l.parent?.remove(l);
l.dispose?.();
}
this._staticLights = [];
}
/** Auto-adjust camera + model position based on bounding box */
_adjustCamera(avatarUrl) {
const head = /** @type {TalkingHead} */ (this.head);
if (!head.scene || !head.camera) return;
if (!avatarUrl && !this._staticModel) {
requestAnimationFrame(() => {
try { head.renderer?.render(head.scene, head.camera); head.camera.updateProjectionMatrix?.(); } catch {}
});
return;
}
try {
const box = new Box3().setFromObject(head.scene);
const size = box.getSize(new Vector3());
const center = box.getCenter(new Vector3());
console.log("[AvatarStage] BBox:", { size, center });
if (size.x === 0 && size.y === 0 && size.z === 0) {
console.warn("[AvatarStage] Empty bbox, skipping");
return;
}
if (size.y > 2.0) {
head.scene.position.y = -(center.y - size.y * 0.35);
head.camera.position.z = -Math.max(size.z, size.y, 1.0) * 1.2;
head.camera.position.y = size.y * 0.3;
console.log("[AvatarStage] Full-body adjusted");
} else if (size.y > 0.3) {
head.camera.position.z = -Math.max(size.z, 0.5) * 2.5;
head.camera.position.y = center.y * 0.5;
console.log("[AvatarStage] Head model adjusted");
}
requestAnimationFrame(() => {
try { head.renderer?.render(head.scene, head.camera); head.camera.updateProjectionMatrix?.(); } catch {}
});
box.dispose();
} catch (e) {
console.warn("[AvatarStage] Camera adjust:", e);
}
}
async _initLipsync() {
const head = /** @type {TalkingHead} */ (this.head);
if (!head.audioCtx) { console.warn("[AvatarStage] No AudioContext"); return; }
try { await head.audioCtx.audioWorklet.addModule(HEADAUDIO_WORKLET_URL); } catch (e) { console.warn("[AvatarStage] Worklet:", e); return; }
const headaudio = new HeadAudio(head.audioCtx);
await headaudio.loadModel(HEADAUDIO_MODEL_URL);
if (!head.audioSpeechGainNode) { console.warn("[AvatarStage] No audioSpeechGainNode"); return; }
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; }
get lastError() { return this._lastError; }
get isStaticModel() { return this._staticModel; }
resume() {
this.head?.start();
if (this.head && this.head.audioCtx.state === "suspended") {
return this.head.audioCtx.resume().catch(() => {});
}
return Promise.resolve();
}
setConversationState(status) {
const h = this.head;
if (!h) return;
switch (status) {
case "user-speaking": h.isSpeaking = false; h.lookAtCamera(800); break;
case "ai-speaking": h.isSpeaking = true; break;
case "processing": h.isSpeaking = false; break;
case "closed": case "error": case "idle": h.isSpeaking = false; h.stopGesture(300); break;
default: h.isSpeaking = false;
}
}
runTool(name, args) {
const h = this.head;
if (!h) return null;
if (this._staticModel) {
if (name === "set_mood") return "Mood unavailable (static model)";
if (name === "make_hand_gesture") return "Gestures unavailable (static model)";
if (name === "make_facial_expression") return "Expressions unavailable (static model)";
}
if (name === "set_mood") {
if (!AVATAR_MOODS.includes(args.mood)) return `Unknown mood: ${args.mood}`;
h.setMood(args.mood); return `Mood set to ${args.mood}.`;
}
if (name === "make_hand_gesture") {
if (!AVATAR_GESTURES.includes(args.gesture)) return `Unknown gesture: ${args.gesture}`;
h.playGesture(args.gesture, 3); return `Playing gesture ${args.gesture}.`;
}
if (name === "make_facial_expression") {
if (!args.emoji) return "No emoji given.";
h.speakEmoji(args.emoji); return `Expressing ${args.emoji}.`;
}
return null;
}
}