@open-avatar/livekit-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +263 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +227 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +29 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
interface LiveKitAvatarProps {
|
|
4
|
+
modelUrl: string;
|
|
5
|
+
bodyStyle?: 'M' | 'F';
|
|
6
|
+
cameraView?: 'upper' | 'full' | 'head';
|
|
7
|
+
avatarMood?: string;
|
|
8
|
+
enableGestures?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
declare function LiveKitAvatar({ modelUrl, bodyStyle, cameraView, avatarMood, enableGestures, className }: LiveKitAvatarProps): react_jsx_runtime.JSX.Element;
|
|
12
|
+
|
|
13
|
+
export { LiveKitAvatar, type LiveKitAvatarProps };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
interface LiveKitAvatarProps {
|
|
4
|
+
modelUrl: string;
|
|
5
|
+
bodyStyle?: 'M' | 'F';
|
|
6
|
+
cameraView?: 'upper' | 'full' | 'head';
|
|
7
|
+
avatarMood?: string;
|
|
8
|
+
enableGestures?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
declare function LiveKitAvatar({ modelUrl, bodyStyle, cameraView, avatarMood, enableGestures, className }: LiveKitAvatarProps): react_jsx_runtime.JSX.Element;
|
|
12
|
+
|
|
13
|
+
export { LiveKitAvatar, type LiveKitAvatarProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
29
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
+
|
|
31
|
+
// src/index.ts
|
|
32
|
+
var index_exports = {};
|
|
33
|
+
__export(index_exports, {
|
|
34
|
+
LiveKitAvatar: () => LiveKitAvatar
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
|
|
38
|
+
// src/LiveKitAvatar.tsx
|
|
39
|
+
var import_react = require("react");
|
|
40
|
+
var import_components_react = require("@livekit/components-react");
|
|
41
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
42
|
+
var CDN_WORKLET_URL = "https://unpkg.com/@met4citizen/headaudio/dist/headworklet.min.mjs";
|
|
43
|
+
var CDN_MODEL_URL = "https://unpkg.com/@met4citizen/headaudio/dist/model-en-mixed.bin";
|
|
44
|
+
var VAD_GATE_ACTIVE_DB = -40;
|
|
45
|
+
var VAD_GATE_INACTIVE_DB = -60;
|
|
46
|
+
function LiveKitAvatar({
|
|
47
|
+
modelUrl,
|
|
48
|
+
bodyStyle = "F",
|
|
49
|
+
cameraView = "upper",
|
|
50
|
+
avatarMood = "neutral",
|
|
51
|
+
enableGestures = true,
|
|
52
|
+
className
|
|
53
|
+
}) {
|
|
54
|
+
const containerRef = (0, import_react.useRef)(null);
|
|
55
|
+
const headRef = (0, import_react.useRef)(null);
|
|
56
|
+
const audioCtxRef = (0, import_react.useRef)(null);
|
|
57
|
+
const headAudioRef = (0, import_react.useRef)(null);
|
|
58
|
+
const sourceNodeRef = (0, import_react.useRef)(null);
|
|
59
|
+
const isInitializedRef = (0, import_react.useRef)(false);
|
|
60
|
+
const [loadingState, setLoadingState] = (0, import_react.useState)("idle");
|
|
61
|
+
const { audioTrack, state: agentState } = (0, import_components_react.useVoiceAssistant)();
|
|
62
|
+
const cleanup = (0, import_react.useCallback)(() => {
|
|
63
|
+
var _a, _b, _c;
|
|
64
|
+
(_a = sourceNodeRef.current) == null ? void 0 : _a.disconnect();
|
|
65
|
+
sourceNodeRef.current = null;
|
|
66
|
+
(_b = headAudioRef.current) == null ? void 0 : _b.stop();
|
|
67
|
+
(_c = headAudioRef.current) == null ? void 0 : _c.disconnect();
|
|
68
|
+
headAudioRef.current = null;
|
|
69
|
+
if (audioCtxRef.current && audioCtxRef.current.state !== "closed") {
|
|
70
|
+
void audioCtxRef.current.close();
|
|
71
|
+
}
|
|
72
|
+
audioCtxRef.current = null;
|
|
73
|
+
if (headRef.current) {
|
|
74
|
+
headRef.current.stop();
|
|
75
|
+
}
|
|
76
|
+
headRef.current = null;
|
|
77
|
+
if (containerRef.current) {
|
|
78
|
+
containerRef.current.innerHTML = "";
|
|
79
|
+
}
|
|
80
|
+
isInitializedRef.current = false;
|
|
81
|
+
}, []);
|
|
82
|
+
(0, import_react.useEffect)(() => {
|
|
83
|
+
if (!containerRef.current) return;
|
|
84
|
+
let isCancelled = false;
|
|
85
|
+
const initializeHead = async () => {
|
|
86
|
+
setLoadingState("loading-head");
|
|
87
|
+
try {
|
|
88
|
+
const { TalkingHead } = await import("@met4citizen/talkinghead");
|
|
89
|
+
if (isCancelled || !containerRef.current) return;
|
|
90
|
+
const head = new TalkingHead(containerRef.current, {
|
|
91
|
+
cameraView,
|
|
92
|
+
avatarMood,
|
|
93
|
+
// Disable built-in text-driven lipsync, we use HeadAudio instead
|
|
94
|
+
lipsyncModules: []
|
|
95
|
+
});
|
|
96
|
+
await head.showAvatar(
|
|
97
|
+
{
|
|
98
|
+
url: modelUrl,
|
|
99
|
+
body: bodyStyle,
|
|
100
|
+
avatarMood,
|
|
101
|
+
lipsyncLang: "en"
|
|
102
|
+
},
|
|
103
|
+
(ev) => {
|
|
104
|
+
if (ev.lengthComputable) {
|
|
105
|
+
const pct = Math.round(ev.loaded / ev.total * 100);
|
|
106
|
+
if (pct === 100) setLoadingState("loading-audio");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
if (isCancelled) return;
|
|
111
|
+
headRef.current = head;
|
|
112
|
+
isInitializedRef.current = true;
|
|
113
|
+
setLoadingState("ready");
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (isCancelled) return;
|
|
116
|
+
console.error("[LiveKitAvatar] Failed to initialize 3D head:", err);
|
|
117
|
+
setLoadingState("error");
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
void initializeHead();
|
|
121
|
+
return () => {
|
|
122
|
+
isCancelled = true;
|
|
123
|
+
cleanup();
|
|
124
|
+
};
|
|
125
|
+
}, [modelUrl, bodyStyle, cameraView, avatarMood, cleanup]);
|
|
126
|
+
(0, import_react.useEffect)(() => {
|
|
127
|
+
var _a;
|
|
128
|
+
if (!headRef.current || !isInitializedRef.current) return;
|
|
129
|
+
if (!((_a = audioTrack == null ? void 0 : audioTrack.publication) == null ? void 0 : _a.track)) return;
|
|
130
|
+
const mediaStreamTrack = audioTrack.publication.track.mediaStreamTrack;
|
|
131
|
+
if (!mediaStreamTrack) return;
|
|
132
|
+
let isCancelled = false;
|
|
133
|
+
const connectAudioBridge = async () => {
|
|
134
|
+
try {
|
|
135
|
+
const audioCtx = new AudioContext();
|
|
136
|
+
audioCtxRef.current = audioCtx;
|
|
137
|
+
await loadWorkletFromCDN(audioCtx, CDN_WORKLET_URL);
|
|
138
|
+
if (isCancelled) {
|
|
139
|
+
await audioCtx.close();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const { HeadAudio } = await import("@met4citizen/headaudio/modules/headaudio.mjs");
|
|
143
|
+
if (isCancelled) {
|
|
144
|
+
await audioCtx.close();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const headaudio = new HeadAudio(audioCtx, {
|
|
148
|
+
parameterData: {
|
|
149
|
+
vadGateActiveDb: VAD_GATE_ACTIVE_DB,
|
|
150
|
+
vadGateInactiveDb: VAD_GATE_INACTIVE_DB
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
headAudioRef.current = headaudio;
|
|
154
|
+
await headaudio.loadModel(CDN_MODEL_URL);
|
|
155
|
+
if (isCancelled) return;
|
|
156
|
+
const head = headRef.current;
|
|
157
|
+
if (!head) return;
|
|
158
|
+
headaudio.onvalue = (key, value) => {
|
|
159
|
+
var _a2;
|
|
160
|
+
if ((_a2 = head.mtAvatar) == null ? void 0 : _a2[key]) {
|
|
161
|
+
Object.assign(head.mtAvatar[key], {
|
|
162
|
+
newvalue: value,
|
|
163
|
+
needsUpdate: true
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
head.opt.update = headaudio.update.bind(headaudio);
|
|
168
|
+
if (enableGestures) {
|
|
169
|
+
let lastEnded = 0;
|
|
170
|
+
headaudio.onended = () => {
|
|
171
|
+
lastEnded = Date.now();
|
|
172
|
+
};
|
|
173
|
+
headaudio.onstarted = () => {
|
|
174
|
+
const duration = Date.now() - lastEnded;
|
|
175
|
+
if (duration > 150) {
|
|
176
|
+
head.lookAtCamera(500);
|
|
177
|
+
head.speakWithHands();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const stream = new MediaStream([mediaStreamTrack]);
|
|
182
|
+
const sourceNode = audioCtx.createMediaStreamSource(stream);
|
|
183
|
+
sourceNode.connect(headaudio);
|
|
184
|
+
sourceNodeRef.current = sourceNode;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error("[LiveKitAvatar] Failed to connect audio bridge:", err);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
void connectAudioBridge();
|
|
190
|
+
return () => {
|
|
191
|
+
var _a2, _b, _c;
|
|
192
|
+
isCancelled = true;
|
|
193
|
+
(_a2 = sourceNodeRef.current) == null ? void 0 : _a2.disconnect();
|
|
194
|
+
sourceNodeRef.current = null;
|
|
195
|
+
(_b = headAudioRef.current) == null ? void 0 : _b.stop();
|
|
196
|
+
(_c = headAudioRef.current) == null ? void 0 : _c.disconnect();
|
|
197
|
+
headAudioRef.current = null;
|
|
198
|
+
if (audioCtxRef.current && audioCtxRef.current.state !== "closed") {
|
|
199
|
+
void audioCtxRef.current.close();
|
|
200
|
+
}
|
|
201
|
+
audioCtxRef.current = null;
|
|
202
|
+
if (headRef.current) {
|
|
203
|
+
headRef.current.opt.update = void 0;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}, [audioTrack, enableGestures]);
|
|
207
|
+
(0, import_react.useEffect)(() => {
|
|
208
|
+
const handleVisibility = () => {
|
|
209
|
+
if (!headRef.current) return;
|
|
210
|
+
if (document.visibilityState === "visible") {
|
|
211
|
+
headRef.current.start();
|
|
212
|
+
} else {
|
|
213
|
+
headRef.current.stop();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
217
|
+
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
|
218
|
+
}, []);
|
|
219
|
+
const isLoading = loadingState === "idle" || loadingState === "loading-head" || loadingState === "loading-audio";
|
|
220
|
+
const isError = loadingState === "error";
|
|
221
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `relative w-full h-full ${className != null ? className : ""}`, children: [
|
|
222
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
223
|
+
"div",
|
|
224
|
+
{
|
|
225
|
+
ref: containerRef,
|
|
226
|
+
className: "w-full h-full",
|
|
227
|
+
style: { minHeight: "300px" }
|
|
228
|
+
}
|
|
229
|
+
),
|
|
230
|
+
isLoading && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-black/20", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-white" }) }),
|
|
231
|
+
isError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-black/40 text-white text-sm", children: "Failed to load avatar" }),
|
|
232
|
+
loadingState === "ready" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AvatarStateOverlay, { agentState })
|
|
233
|
+
] });
|
|
234
|
+
}
|
|
235
|
+
function AvatarStateOverlay({ agentState }) {
|
|
236
|
+
const stateConfig = {
|
|
237
|
+
listening: { label: "Listening" },
|
|
238
|
+
speaking: { label: "Speaking" },
|
|
239
|
+
thinking: { label: "Processing" },
|
|
240
|
+
connecting: { label: "Connecting" },
|
|
241
|
+
initializing: { label: "Initializing" },
|
|
242
|
+
idle: { label: "Ready" },
|
|
243
|
+
disconnected: { label: "Disconnected" },
|
|
244
|
+
failed: { label: "Failed" },
|
|
245
|
+
"pre-connect-buffering": { label: "Buffering" }
|
|
246
|
+
};
|
|
247
|
+
const config = stateConfig[agentState];
|
|
248
|
+
if (!config) return null;
|
|
249
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute bottom-3 left-3 text-xs text-white bg-black/40 px-2 py-1 rounded", children: config.label });
|
|
250
|
+
}
|
|
251
|
+
async function loadWorkletFromCDN(ctx, url) {
|
|
252
|
+
const res = await fetch(url);
|
|
253
|
+
const text = await res.text();
|
|
254
|
+
const blob = new Blob([text], { type: "application/javascript" });
|
|
255
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
256
|
+
await ctx.audioWorklet.addModule(blobUrl);
|
|
257
|
+
URL.revokeObjectURL(blobUrl);
|
|
258
|
+
}
|
|
259
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
260
|
+
0 && (module.exports = {
|
|
261
|
+
LiveKitAvatar
|
|
262
|
+
});
|
|
263
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/LiveKitAvatar.tsx"],"sourcesContent":["export { LiveKitAvatar } from './LiveKitAvatar';\nexport type { LiveKitAvatarProps } from './LiveKitAvatar';","import { useEffect, useRef, useState, useCallback } from 'react';\nimport { useVoiceAssistant } from '@livekit/components-react';\nimport type { AgentState } from '@livekit/components-react';\nimport type { TalkingHead } from '@met4citizen/talkinghead';\nimport type { HeadAudio } from '@met4citizen/headaudio/modules/headaudio.mjs';\n\nconst CDN_WORKLET_URL =\n 'https://unpkg.com/@met4citizen/headaudio/dist/headworklet.min.mjs';\nconst CDN_MODEL_URL =\n 'https://unpkg.com/@met4citizen/headaudio/dist/model-en-mixed.bin';\n\nconst VAD_GATE_ACTIVE_DB = -40;\nconst VAD_GATE_INACTIVE_DB = -60;\n\nexport interface LiveKitAvatarProps {\n modelUrl: string;\n bodyStyle?: 'M' | 'F';\n cameraView?: 'upper' | 'full' | 'head';\n avatarMood?: string;\n enableGestures?: boolean;\n className?: string;\n}\n\ntype AvatarLoadingState = 'idle' | 'loading-head' | 'loading-audio' | 'ready' | 'error';\n\nexport function LiveKitAvatar({\n modelUrl,\n bodyStyle = 'F',\n cameraView = 'upper',\n avatarMood = 'neutral',\n enableGestures = true,\n className\n}: LiveKitAvatarProps) {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const headRef = useRef<TalkingHead | null>(null);\n const audioCtxRef = useRef<AudioContext | null>(null);\n const headAudioRef = useRef<HeadAudio | null>(null);\n const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);\n const isInitializedRef = useRef<boolean>(false);\n\n const [loadingState, setLoadingState] = useState<AvatarLoadingState>('idle');\n\n const { audioTrack, state: agentState } = useVoiceAssistant();\n\n const cleanup = useCallback(() => {\n sourceNodeRef.current?.disconnect();\n sourceNodeRef.current = null;\n headAudioRef.current?.stop();\n headAudioRef.current?.disconnect();\n headAudioRef.current = null;\n\n if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') {\n void audioCtxRef.current.close();\n }\n audioCtxRef.current = null;\n\n if (headRef.current) {\n headRef.current.stop();\n }\n headRef.current = null;\n\n if (containerRef.current) {\n containerRef.current.innerHTML = '';\n }\n\n isInitializedRef.current = false;\n }, []);\n\n useEffect(() => {\n if (!containerRef.current) return;\n\n let isCancelled = false;\n\n const initializeHead = async () => {\n setLoadingState('loading-head');\n\n try {\n const { TalkingHead } = await import('@met4citizen/talkinghead');\n\n if (isCancelled || !containerRef.current) return;\n\n const head = new TalkingHead(containerRef.current, {\n cameraView,\n avatarMood,\n // Disable built-in text-driven lipsync, we use HeadAudio instead\n lipsyncModules: []\n });\n\n await head.showAvatar(\n {\n url: modelUrl,\n body: bodyStyle,\n avatarMood,\n lipsyncLang: 'en'\n },\n (ev: ProgressEvent) => {\n if (ev.lengthComputable) {\n const pct = Math.round((ev.loaded / ev.total) * 100);\n if (pct === 100) setLoadingState('loading-audio');\n }\n }\n );\n\n if (isCancelled) return;\n\n headRef.current = head;\n isInitializedRef.current = true;\n setLoadingState('ready');\n } catch (err) {\n if (isCancelled) return;\n // eslint-disable-next-line no-console\n console.error('[LiveKitAvatar] Failed to initialize 3D head:', err);\n setLoadingState('error');\n }\n };\n\n void initializeHead();\n\n return () => {\n isCancelled = true;\n cleanup();\n };\n }, [modelUrl, bodyStyle, cameraView, avatarMood, cleanup]);\n\n useEffect(() => {\n if (!headRef.current || !isInitializedRef.current) return;\n if (!audioTrack?.publication?.track) return;\n\n const mediaStreamTrack = audioTrack.publication.track.mediaStreamTrack;\n if (!mediaStreamTrack) return;\n\n let isCancelled = false;\n\n const connectAudioBridge = async () => {\n try {\n const audioCtx = new AudioContext();\n audioCtxRef.current = audioCtx;\n\n await loadWorkletFromCDN(audioCtx, CDN_WORKLET_URL);\n\n if (isCancelled) {\n await audioCtx.close();\n return;\n }\n\n const { HeadAudio } = await import(\n '@met4citizen/headaudio/modules/headaudio.mjs'\n );\n\n if (isCancelled) {\n await audioCtx.close();\n return;\n }\n\n const headaudio = new HeadAudio(audioCtx, {\n parameterData: {\n vadGateActiveDb: VAD_GATE_ACTIVE_DB,\n vadGateInactiveDb: VAD_GATE_INACTIVE_DB\n }\n });\n headAudioRef.current = headaudio;\n\n await headaudio.loadModel(CDN_MODEL_URL);\n\n if (isCancelled) return;\n\n const head = headRef.current;\n if (!head) return;\n\n headaudio.onvalue = (key: string, value: number) => {\n if (head.mtAvatar?.[key]) {\n Object.assign(head.mtAvatar[key], {\n newvalue: value,\n needsUpdate: true\n });\n }\n };\n\n head.opt.update = headaudio.update.bind(headaudio);\n\n if (enableGestures) {\n let lastEnded = 0;\n headaudio.onended = () => {\n lastEnded = Date.now();\n };\n headaudio.onstarted = () => {\n const duration = Date.now() - lastEnded;\n if (duration > 150) {\n head.lookAtCamera(500);\n head.speakWithHands();\n }\n };\n }\n\n const stream = new MediaStream([mediaStreamTrack]);\n const sourceNode = audioCtx.createMediaStreamSource(stream);\n sourceNode.connect(headaudio);\n sourceNodeRef.current = sourceNode;\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[LiveKitAvatar] Failed to connect audio bridge:', err);\n }\n };\n\n void connectAudioBridge();\n\n return () => {\n isCancelled = true;\n sourceNodeRef.current?.disconnect();\n sourceNodeRef.current = null;\n headAudioRef.current?.stop();\n headAudioRef.current?.disconnect();\n headAudioRef.current = null;\n if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') {\n void audioCtxRef.current.close();\n }\n audioCtxRef.current = null;\n if (headRef.current) {\n headRef.current.opt.update = undefined;\n }\n };\n }, [audioTrack, enableGestures]);\n\n useEffect(() => {\n const handleVisibility = () => {\n if (!headRef.current) return;\n if (document.visibilityState === 'visible') {\n headRef.current.start();\n } else {\n headRef.current.stop();\n }\n };\n\n document.addEventListener('visibilitychange', handleVisibility);\n return () => document.removeEventListener('visibilitychange', handleVisibility);\n }, []);\n\n const isLoading =\n loadingState === 'idle' ||\n loadingState === 'loading-head' ||\n loadingState === 'loading-audio';\n const isError = loadingState === 'error';\n\n return (\n <div className={`relative w-full h-full ${className ?? ''}`}>\n <div\n ref={containerRef}\n className=\"w-full h-full\"\n style={{ minHeight: '300px' }}\n />\n\n {isLoading && (\n <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-black/20\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-white\" />\n </div>\n )}\n\n {isError && (\n <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-black/40 text-white text-sm\">\n Failed to load avatar\n </div>\n )}\n\n {loadingState === 'ready' && <AvatarStateOverlay agentState={agentState} />}\n </div>\n );\n}\n\nfunction AvatarStateOverlay({ agentState }: { agentState: AgentState }) {\n const stateConfig: Record<string, { label: string }> = {\n listening: { label: 'Listening' },\n speaking: { label: 'Speaking' },\n thinking: { label: 'Processing' },\n connecting: { label: 'Connecting' },\n initializing: { label: 'Initializing' },\n idle: { label: 'Ready' },\n disconnected: { label: 'Disconnected' },\n failed: { label: 'Failed' },\n 'pre-connect-buffering': { label: 'Buffering' }\n };\n\n const config = stateConfig[agentState];\n if (!config) return null;\n\n return (\n <div className=\"absolute bottom-3 left-3 text-xs text-white bg-black/40 px-2 py-1 rounded\">\n {config.label}\n </div>\n );\n}\n\nasync function loadWorkletFromCDN(\n ctx: AudioContext,\n url: string\n): Promise<void> {\n const res = await fetch(url);\n const text = await res.text();\n const blob = new Blob([text], { type: 'application/javascript' });\n const blobUrl = URL.createObjectURL(blob);\n await ctx.audioWorklet.addModule(blobUrl);\n URL.revokeObjectURL(blobUrl);\n}"],"mappings":"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","names":["_a"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/LiveKitAvatar.tsx
|
|
4
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
5
|
+
import { useVoiceAssistant } from "@livekit/components-react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
var CDN_WORKLET_URL = "https://unpkg.com/@met4citizen/headaudio/dist/headworklet.min.mjs";
|
|
8
|
+
var CDN_MODEL_URL = "https://unpkg.com/@met4citizen/headaudio/dist/model-en-mixed.bin";
|
|
9
|
+
var VAD_GATE_ACTIVE_DB = -40;
|
|
10
|
+
var VAD_GATE_INACTIVE_DB = -60;
|
|
11
|
+
function LiveKitAvatar({
|
|
12
|
+
modelUrl,
|
|
13
|
+
bodyStyle = "F",
|
|
14
|
+
cameraView = "upper",
|
|
15
|
+
avatarMood = "neutral",
|
|
16
|
+
enableGestures = true,
|
|
17
|
+
className
|
|
18
|
+
}) {
|
|
19
|
+
const containerRef = useRef(null);
|
|
20
|
+
const headRef = useRef(null);
|
|
21
|
+
const audioCtxRef = useRef(null);
|
|
22
|
+
const headAudioRef = useRef(null);
|
|
23
|
+
const sourceNodeRef = useRef(null);
|
|
24
|
+
const isInitializedRef = useRef(false);
|
|
25
|
+
const [loadingState, setLoadingState] = useState("idle");
|
|
26
|
+
const { audioTrack, state: agentState } = useVoiceAssistant();
|
|
27
|
+
const cleanup = useCallback(() => {
|
|
28
|
+
var _a, _b, _c;
|
|
29
|
+
(_a = sourceNodeRef.current) == null ? void 0 : _a.disconnect();
|
|
30
|
+
sourceNodeRef.current = null;
|
|
31
|
+
(_b = headAudioRef.current) == null ? void 0 : _b.stop();
|
|
32
|
+
(_c = headAudioRef.current) == null ? void 0 : _c.disconnect();
|
|
33
|
+
headAudioRef.current = null;
|
|
34
|
+
if (audioCtxRef.current && audioCtxRef.current.state !== "closed") {
|
|
35
|
+
void audioCtxRef.current.close();
|
|
36
|
+
}
|
|
37
|
+
audioCtxRef.current = null;
|
|
38
|
+
if (headRef.current) {
|
|
39
|
+
headRef.current.stop();
|
|
40
|
+
}
|
|
41
|
+
headRef.current = null;
|
|
42
|
+
if (containerRef.current) {
|
|
43
|
+
containerRef.current.innerHTML = "";
|
|
44
|
+
}
|
|
45
|
+
isInitializedRef.current = false;
|
|
46
|
+
}, []);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!containerRef.current) return;
|
|
49
|
+
let isCancelled = false;
|
|
50
|
+
const initializeHead = async () => {
|
|
51
|
+
setLoadingState("loading-head");
|
|
52
|
+
try {
|
|
53
|
+
const { TalkingHead } = await import("@met4citizen/talkinghead");
|
|
54
|
+
if (isCancelled || !containerRef.current) return;
|
|
55
|
+
const head = new TalkingHead(containerRef.current, {
|
|
56
|
+
cameraView,
|
|
57
|
+
avatarMood,
|
|
58
|
+
// Disable built-in text-driven lipsync, we use HeadAudio instead
|
|
59
|
+
lipsyncModules: []
|
|
60
|
+
});
|
|
61
|
+
await head.showAvatar(
|
|
62
|
+
{
|
|
63
|
+
url: modelUrl,
|
|
64
|
+
body: bodyStyle,
|
|
65
|
+
avatarMood,
|
|
66
|
+
lipsyncLang: "en"
|
|
67
|
+
},
|
|
68
|
+
(ev) => {
|
|
69
|
+
if (ev.lengthComputable) {
|
|
70
|
+
const pct = Math.round(ev.loaded / ev.total * 100);
|
|
71
|
+
if (pct === 100) setLoadingState("loading-audio");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
if (isCancelled) return;
|
|
76
|
+
headRef.current = head;
|
|
77
|
+
isInitializedRef.current = true;
|
|
78
|
+
setLoadingState("ready");
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (isCancelled) return;
|
|
81
|
+
console.error("[LiveKitAvatar] Failed to initialize 3D head:", err);
|
|
82
|
+
setLoadingState("error");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
void initializeHead();
|
|
86
|
+
return () => {
|
|
87
|
+
isCancelled = true;
|
|
88
|
+
cleanup();
|
|
89
|
+
};
|
|
90
|
+
}, [modelUrl, bodyStyle, cameraView, avatarMood, cleanup]);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
var _a;
|
|
93
|
+
if (!headRef.current || !isInitializedRef.current) return;
|
|
94
|
+
if (!((_a = audioTrack == null ? void 0 : audioTrack.publication) == null ? void 0 : _a.track)) return;
|
|
95
|
+
const mediaStreamTrack = audioTrack.publication.track.mediaStreamTrack;
|
|
96
|
+
if (!mediaStreamTrack) return;
|
|
97
|
+
let isCancelled = false;
|
|
98
|
+
const connectAudioBridge = async () => {
|
|
99
|
+
try {
|
|
100
|
+
const audioCtx = new AudioContext();
|
|
101
|
+
audioCtxRef.current = audioCtx;
|
|
102
|
+
await loadWorkletFromCDN(audioCtx, CDN_WORKLET_URL);
|
|
103
|
+
if (isCancelled) {
|
|
104
|
+
await audioCtx.close();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const { HeadAudio } = await import("@met4citizen/headaudio/modules/headaudio.mjs");
|
|
108
|
+
if (isCancelled) {
|
|
109
|
+
await audioCtx.close();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const headaudio = new HeadAudio(audioCtx, {
|
|
113
|
+
parameterData: {
|
|
114
|
+
vadGateActiveDb: VAD_GATE_ACTIVE_DB,
|
|
115
|
+
vadGateInactiveDb: VAD_GATE_INACTIVE_DB
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
headAudioRef.current = headaudio;
|
|
119
|
+
await headaudio.loadModel(CDN_MODEL_URL);
|
|
120
|
+
if (isCancelled) return;
|
|
121
|
+
const head = headRef.current;
|
|
122
|
+
if (!head) return;
|
|
123
|
+
headaudio.onvalue = (key, value) => {
|
|
124
|
+
var _a2;
|
|
125
|
+
if ((_a2 = head.mtAvatar) == null ? void 0 : _a2[key]) {
|
|
126
|
+
Object.assign(head.mtAvatar[key], {
|
|
127
|
+
newvalue: value,
|
|
128
|
+
needsUpdate: true
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
head.opt.update = headaudio.update.bind(headaudio);
|
|
133
|
+
if (enableGestures) {
|
|
134
|
+
let lastEnded = 0;
|
|
135
|
+
headaudio.onended = () => {
|
|
136
|
+
lastEnded = Date.now();
|
|
137
|
+
};
|
|
138
|
+
headaudio.onstarted = () => {
|
|
139
|
+
const duration = Date.now() - lastEnded;
|
|
140
|
+
if (duration > 150) {
|
|
141
|
+
head.lookAtCamera(500);
|
|
142
|
+
head.speakWithHands();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const stream = new MediaStream([mediaStreamTrack]);
|
|
147
|
+
const sourceNode = audioCtx.createMediaStreamSource(stream);
|
|
148
|
+
sourceNode.connect(headaudio);
|
|
149
|
+
sourceNodeRef.current = sourceNode;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[LiveKitAvatar] Failed to connect audio bridge:", err);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
void connectAudioBridge();
|
|
155
|
+
return () => {
|
|
156
|
+
var _a2, _b, _c;
|
|
157
|
+
isCancelled = true;
|
|
158
|
+
(_a2 = sourceNodeRef.current) == null ? void 0 : _a2.disconnect();
|
|
159
|
+
sourceNodeRef.current = null;
|
|
160
|
+
(_b = headAudioRef.current) == null ? void 0 : _b.stop();
|
|
161
|
+
(_c = headAudioRef.current) == null ? void 0 : _c.disconnect();
|
|
162
|
+
headAudioRef.current = null;
|
|
163
|
+
if (audioCtxRef.current && audioCtxRef.current.state !== "closed") {
|
|
164
|
+
void audioCtxRef.current.close();
|
|
165
|
+
}
|
|
166
|
+
audioCtxRef.current = null;
|
|
167
|
+
if (headRef.current) {
|
|
168
|
+
headRef.current.opt.update = void 0;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}, [audioTrack, enableGestures]);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const handleVisibility = () => {
|
|
174
|
+
if (!headRef.current) return;
|
|
175
|
+
if (document.visibilityState === "visible") {
|
|
176
|
+
headRef.current.start();
|
|
177
|
+
} else {
|
|
178
|
+
headRef.current.stop();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
182
|
+
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
|
183
|
+
}, []);
|
|
184
|
+
const isLoading = loadingState === "idle" || loadingState === "loading-head" || loadingState === "loading-audio";
|
|
185
|
+
const isError = loadingState === "error";
|
|
186
|
+
return /* @__PURE__ */ jsxs("div", { className: `relative w-full h-full ${className != null ? className : ""}`, children: [
|
|
187
|
+
/* @__PURE__ */ jsx(
|
|
188
|
+
"div",
|
|
189
|
+
{
|
|
190
|
+
ref: containerRef,
|
|
191
|
+
className: "w-full h-full",
|
|
192
|
+
style: { minHeight: "300px" }
|
|
193
|
+
}
|
|
194
|
+
),
|
|
195
|
+
isLoading && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-black/20", children: /* @__PURE__ */ jsx("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-white" }) }),
|
|
196
|
+
isError && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 flex flex-col items-center justify-center bg-black/40 text-white text-sm", children: "Failed to load avatar" }),
|
|
197
|
+
loadingState === "ready" && /* @__PURE__ */ jsx(AvatarStateOverlay, { agentState })
|
|
198
|
+
] });
|
|
199
|
+
}
|
|
200
|
+
function AvatarStateOverlay({ agentState }) {
|
|
201
|
+
const stateConfig = {
|
|
202
|
+
listening: { label: "Listening" },
|
|
203
|
+
speaking: { label: "Speaking" },
|
|
204
|
+
thinking: { label: "Processing" },
|
|
205
|
+
connecting: { label: "Connecting" },
|
|
206
|
+
initializing: { label: "Initializing" },
|
|
207
|
+
idle: { label: "Ready" },
|
|
208
|
+
disconnected: { label: "Disconnected" },
|
|
209
|
+
failed: { label: "Failed" },
|
|
210
|
+
"pre-connect-buffering": { label: "Buffering" }
|
|
211
|
+
};
|
|
212
|
+
const config = stateConfig[agentState];
|
|
213
|
+
if (!config) return null;
|
|
214
|
+
return /* @__PURE__ */ jsx("div", { className: "absolute bottom-3 left-3 text-xs text-white bg-black/40 px-2 py-1 rounded", children: config.label });
|
|
215
|
+
}
|
|
216
|
+
async function loadWorkletFromCDN(ctx, url) {
|
|
217
|
+
const res = await fetch(url);
|
|
218
|
+
const text = await res.text();
|
|
219
|
+
const blob = new Blob([text], { type: "application/javascript" });
|
|
220
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
221
|
+
await ctx.audioWorklet.addModule(blobUrl);
|
|
222
|
+
URL.revokeObjectURL(blobUrl);
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
LiveKitAvatar
|
|
226
|
+
};
|
|
227
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/LiveKitAvatar.tsx"],"sourcesContent":["import { useEffect, useRef, useState, useCallback } from 'react';\nimport { useVoiceAssistant } from '@livekit/components-react';\nimport type { AgentState } from '@livekit/components-react';\nimport type { TalkingHead } from '@met4citizen/talkinghead';\nimport type { HeadAudio } from '@met4citizen/headaudio/modules/headaudio.mjs';\n\nconst CDN_WORKLET_URL =\n 'https://unpkg.com/@met4citizen/headaudio/dist/headworklet.min.mjs';\nconst CDN_MODEL_URL =\n 'https://unpkg.com/@met4citizen/headaudio/dist/model-en-mixed.bin';\n\nconst VAD_GATE_ACTIVE_DB = -40;\nconst VAD_GATE_INACTIVE_DB = -60;\n\nexport interface LiveKitAvatarProps {\n modelUrl: string;\n bodyStyle?: 'M' | 'F';\n cameraView?: 'upper' | 'full' | 'head';\n avatarMood?: string;\n enableGestures?: boolean;\n className?: string;\n}\n\ntype AvatarLoadingState = 'idle' | 'loading-head' | 'loading-audio' | 'ready' | 'error';\n\nexport function LiveKitAvatar({\n modelUrl,\n bodyStyle = 'F',\n cameraView = 'upper',\n avatarMood = 'neutral',\n enableGestures = true,\n className\n}: LiveKitAvatarProps) {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const headRef = useRef<TalkingHead | null>(null);\n const audioCtxRef = useRef<AudioContext | null>(null);\n const headAudioRef = useRef<HeadAudio | null>(null);\n const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);\n const isInitializedRef = useRef<boolean>(false);\n\n const [loadingState, setLoadingState] = useState<AvatarLoadingState>('idle');\n\n const { audioTrack, state: agentState } = useVoiceAssistant();\n\n const cleanup = useCallback(() => {\n sourceNodeRef.current?.disconnect();\n sourceNodeRef.current = null;\n headAudioRef.current?.stop();\n headAudioRef.current?.disconnect();\n headAudioRef.current = null;\n\n if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') {\n void audioCtxRef.current.close();\n }\n audioCtxRef.current = null;\n\n if (headRef.current) {\n headRef.current.stop();\n }\n headRef.current = null;\n\n if (containerRef.current) {\n containerRef.current.innerHTML = '';\n }\n\n isInitializedRef.current = false;\n }, []);\n\n useEffect(() => {\n if (!containerRef.current) return;\n\n let isCancelled = false;\n\n const initializeHead = async () => {\n setLoadingState('loading-head');\n\n try {\n const { TalkingHead } = await import('@met4citizen/talkinghead');\n\n if (isCancelled || !containerRef.current) return;\n\n const head = new TalkingHead(containerRef.current, {\n cameraView,\n avatarMood,\n // Disable built-in text-driven lipsync, we use HeadAudio instead\n lipsyncModules: []\n });\n\n await head.showAvatar(\n {\n url: modelUrl,\n body: bodyStyle,\n avatarMood,\n lipsyncLang: 'en'\n },\n (ev: ProgressEvent) => {\n if (ev.lengthComputable) {\n const pct = Math.round((ev.loaded / ev.total) * 100);\n if (pct === 100) setLoadingState('loading-audio');\n }\n }\n );\n\n if (isCancelled) return;\n\n headRef.current = head;\n isInitializedRef.current = true;\n setLoadingState('ready');\n } catch (err) {\n if (isCancelled) return;\n // eslint-disable-next-line no-console\n console.error('[LiveKitAvatar] Failed to initialize 3D head:', err);\n setLoadingState('error');\n }\n };\n\n void initializeHead();\n\n return () => {\n isCancelled = true;\n cleanup();\n };\n }, [modelUrl, bodyStyle, cameraView, avatarMood, cleanup]);\n\n useEffect(() => {\n if (!headRef.current || !isInitializedRef.current) return;\n if (!audioTrack?.publication?.track) return;\n\n const mediaStreamTrack = audioTrack.publication.track.mediaStreamTrack;\n if (!mediaStreamTrack) return;\n\n let isCancelled = false;\n\n const connectAudioBridge = async () => {\n try {\n const audioCtx = new AudioContext();\n audioCtxRef.current = audioCtx;\n\n await loadWorkletFromCDN(audioCtx, CDN_WORKLET_URL);\n\n if (isCancelled) {\n await audioCtx.close();\n return;\n }\n\n const { HeadAudio } = await import(\n '@met4citizen/headaudio/modules/headaudio.mjs'\n );\n\n if (isCancelled) {\n await audioCtx.close();\n return;\n }\n\n const headaudio = new HeadAudio(audioCtx, {\n parameterData: {\n vadGateActiveDb: VAD_GATE_ACTIVE_DB,\n vadGateInactiveDb: VAD_GATE_INACTIVE_DB\n }\n });\n headAudioRef.current = headaudio;\n\n await headaudio.loadModel(CDN_MODEL_URL);\n\n if (isCancelled) return;\n\n const head = headRef.current;\n if (!head) return;\n\n headaudio.onvalue = (key: string, value: number) => {\n if (head.mtAvatar?.[key]) {\n Object.assign(head.mtAvatar[key], {\n newvalue: value,\n needsUpdate: true\n });\n }\n };\n\n head.opt.update = headaudio.update.bind(headaudio);\n\n if (enableGestures) {\n let lastEnded = 0;\n headaudio.onended = () => {\n lastEnded = Date.now();\n };\n headaudio.onstarted = () => {\n const duration = Date.now() - lastEnded;\n if (duration > 150) {\n head.lookAtCamera(500);\n head.speakWithHands();\n }\n };\n }\n\n const stream = new MediaStream([mediaStreamTrack]);\n const sourceNode = audioCtx.createMediaStreamSource(stream);\n sourceNode.connect(headaudio);\n sourceNodeRef.current = sourceNode;\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[LiveKitAvatar] Failed to connect audio bridge:', err);\n }\n };\n\n void connectAudioBridge();\n\n return () => {\n isCancelled = true;\n sourceNodeRef.current?.disconnect();\n sourceNodeRef.current = null;\n headAudioRef.current?.stop();\n headAudioRef.current?.disconnect();\n headAudioRef.current = null;\n if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') {\n void audioCtxRef.current.close();\n }\n audioCtxRef.current = null;\n if (headRef.current) {\n headRef.current.opt.update = undefined;\n }\n };\n }, [audioTrack, enableGestures]);\n\n useEffect(() => {\n const handleVisibility = () => {\n if (!headRef.current) return;\n if (document.visibilityState === 'visible') {\n headRef.current.start();\n } else {\n headRef.current.stop();\n }\n };\n\n document.addEventListener('visibilitychange', handleVisibility);\n return () => document.removeEventListener('visibilitychange', handleVisibility);\n }, []);\n\n const isLoading =\n loadingState === 'idle' ||\n loadingState === 'loading-head' ||\n loadingState === 'loading-audio';\n const isError = loadingState === 'error';\n\n return (\n <div className={`relative w-full h-full ${className ?? ''}`}>\n <div\n ref={containerRef}\n className=\"w-full h-full\"\n style={{ minHeight: '300px' }}\n />\n\n {isLoading && (\n <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-black/20\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-white\" />\n </div>\n )}\n\n {isError && (\n <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-black/40 text-white text-sm\">\n Failed to load avatar\n </div>\n )}\n\n {loadingState === 'ready' && <AvatarStateOverlay agentState={agentState} />}\n </div>\n );\n}\n\nfunction AvatarStateOverlay({ agentState }: { agentState: AgentState }) {\n const stateConfig: Record<string, { label: string }> = {\n listening: { label: 'Listening' },\n speaking: { label: 'Speaking' },\n thinking: { label: 'Processing' },\n connecting: { label: 'Connecting' },\n initializing: { label: 'Initializing' },\n idle: { label: 'Ready' },\n disconnected: { label: 'Disconnected' },\n failed: { label: 'Failed' },\n 'pre-connect-buffering': { label: 'Buffering' }\n };\n\n const config = stateConfig[agentState];\n if (!config) return null;\n\n return (\n <div className=\"absolute bottom-3 left-3 text-xs text-white bg-black/40 px-2 py-1 rounded\">\n {config.label}\n </div>\n );\n}\n\nasync function loadWorkletFromCDN(\n ctx: AudioContext,\n url: string\n): Promise<void> {\n const res = await fetch(url);\n const text = await res.text();\n const blob = new Blob([text], { type: 'application/javascript' });\n const blobUrl = URL.createObjectURL(blob);\n await ctx.audioWorklet.addModule(blobUrl);\n URL.revokeObjectURL(blobUrl);\n}"],"mappings":"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","names":["_a"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-avatar/livekit-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"author": "jempf",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/jempf/open-avatar-livekit.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/jempf/open-avatar-livekit#readme",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"module": "dist/index.mjs",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsup --watch"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18",
|
|
21
|
+
"react-dom": ">=18",
|
|
22
|
+
"@livekit/components-react": ">=2.9.0",
|
|
23
|
+
"livekit-client": ">=2.17.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@met4citizen/talkinghead": "^1.7.0",
|
|
27
|
+
"@met4citizen/headaudio": "^0.1.0"
|
|
28
|
+
}
|
|
29
|
+
}
|