Spaces:
Paused
Paused
File size: 6,923 Bytes
578cfab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | // @ts-check
/**
* The avatar stage: a TalkingHead 3D avatar with real-time audio-driven
* lip-sync (HeadAudio), plus the choreography that makes it feel present.
*
* How the mouth moves: the speech-to-speech backend sends raw PCM only β no
* word timings, no visemes β so lip-sync is derived from the audio itself.
* The s2s client's playback worklet is routed into TalkingHead's audio graph
* (`audioAnalyzerNode β audioSpeechGainNode β reverb β speakers`), and the
* HeadAudio worklet taps `audioSpeechGainNode`, classifying MFCC frames into
* Oculus visemes (~50 ms behind the audio). Its `onvalue` callback writes the
* viseme blendshapes straight into TalkingHead's morph-target state.
*
* Everything else β blinking, breathing, idle head sway, moods, gestures,
* emojis β is TalkingHead's built-in animation system, steered from here.
*/
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 {
/** @param {HTMLElement} container */
constructor(container) {
this._container = container;
/** @type {TalkingHead | null} */
this.head = null;
/** @type {any | null} */
this._headaudio = null;
this._lastSpeechEnded = 0;
}
/**
* Create the TalkingHead scene and load the avatar + lip-sync model.
* @param {{ avatarUrl?: string, body?: "F" | "M", onprogress?: (ev: ProgressEvent) => void }} [opt]
*/
async init(opt = {}) {
// Lighting all zeroed: the scene is lit by TalkingHead's built-in
// RoomEnvironment IBL, which reads better on skin than the default lights
// (same setup as met4citizen's own realtime speech-to-speech demo).
this.head = new TalkingHead(this._container, {
ttsEndpoint: "N/A", // never used: speech comes from the s2s backend
lipsyncModules: [], // never used: HeadAudio drives the visemes
// Framing tuned by hand: close-up head-and-shoulders with ~7% headroom,
// face centered (cameraX compensates the idle pose's slight lean). The
// vertical FOV makes this framing hold across aspect ratios, so the same
// values work for desktop and phones.
cameraView: "upper",
cameraDistance: -1.4,
cameraY: -0.15,
cameraX: -0.18,
cameraRotateEnable: false,
lightAmbientIntensity: 0,
lightDirectIntensity: 0,
lightSpotIntensity: 0,
// modelPixelRatio is multiplied by devicePixelRatio internally β leave
// it at 1 or retina displays get a 4x drawing buffer.
avatarIdleEyeContact: 0.3,
avatarSpeakingEyeContact: 0.7,
});
await this.head.showAvatar(
{
url: opt.avatarUrl ?? "/avatars/meshforge.glb",
body: opt.body ?? "F",
avatarMood: "neutral",
},
opt.onprogress ?? null,
);
await this._initLipsync();
}
async _initLipsync() {
const head = /** @type {TalkingHead} */ (this.head);
await head.audioCtx.audioWorklet.addModule(HEADAUDIO_WORKLET_URL);
const headaudio = new HeadAudio(head.audioCtx);
await headaudio.loadModel(HEADAUDIO_MODEL_URL);
// Tap the speech path for viseme detection. The audible path continues
// through TalkingHead's own graph untouched.
head.audioSpeechGainNode.connect(headaudio);
// Detected visemes β morph targets, applied inside the render loop.
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);
// Utterance boundaries: after a real pause, re-engage the user β eye
// contact plus conversational hand movement (same trick as the demo).
headaudio.onended = () => {
this._lastSpeechEnded = Date.now();
};
headaudio.onstarted = () => {
if (Date.now() - this._lastSpeechEnded > 150) {
head.lookAtCamera(500);
head.speakWithHands();
}
};
this._headaudio = headaudio;
}
/** The shared AudioContext everything (mic, playback, lip-sync) runs on. */
get audioCtx() {
return this.head?.audioCtx ?? null;
}
/** Where the s2s client should route the TTS playback signal. */
get voiceSink() {
return this.head?.audioAnalyzerNode ?? null;
}
/** Resume audio + animation from within a user gesture (iOS requirement). */
resume() {
this.head?.start();
if (this.head && this.head.audioCtx.state === "suspended") {
this.head.audioCtx.resume().catch(() => {});
}
}
/**
* Conversation-state choreography. Statuses come from the s2s client.
* @param {string} status
*/
setConversationState(status) {
const head = this.head;
if (!head) return;
switch (status) {
case "user-speaking":
// The user started talking (this is also the barge-in moment β the
// playback buffer was just cleared, so HeadAudio hears silence and
// the mouth settles on its own). Give them the avatar's attention.
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;
}
}
/**
* Execute an avatar-control tool called by the model.
* @param {string} name @param {Record<string, unknown>} args
* @returns {string | null} Result text for the model, or null if `name`
* isn't an avatar tool.
*/
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;
}
}
|