@facemode/agents-plugin-facemode 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/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright © 2026 InfinityLevel OPC Private Limited
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @facemode/agents-plugin-facemode
2
+
3
+ FaceMode avatar output for LiveKit Agents JavaScript applications.
4
+
5
+ ## Install
6
+
7
+ ```powershell
8
+ npm install @facemode/agents-plugin-facemode
9
+ ```
10
+
11
+ From this repository:
12
+
13
+ ```powershell
14
+ npm install
15
+ npm run build
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ Pass a server-side LiveKit room object to `start()`. The room token must allow
21
+ the worker participant to join and publish audio/video in the customer room.
22
+ Keep the token server-side and never log it or expose it to browser clients.
23
+
24
+ ```ts
25
+ import { AvatarSession } from '@facemode/agents-plugin-facemode';
26
+
27
+ const avatar = new AvatarSession({
28
+ apiKey: process.env.FACEMODE_API_KEY!,
29
+ avatarId: process.env.FACEMODE_AVATAR_ID,
30
+ });
31
+
32
+ await avatar.start(agentSession, ctx.room, {
33
+ room: {
34
+ type: 'livekit',
35
+ url: process.env.LIVEKIT_URL!,
36
+ token: process.env.LIVEKIT_TOKEN!,
37
+ },
38
+ });
39
+ await avatar.waitForJoin();
40
+ ```
41
+
42
+ The plugin creates a FaceMode session through `POST /api/sessions` with the
43
+ room object, negotiates the canonical WebSocket protocol, and replaces the
44
+ AgentSession audio tail. TTS frames are forwarded as binary `pcm_s16le`;
45
+ interruptions send `cancel_utterance`.
46
+
47
+ The plugin accepts integer PCM sample rates from 8kHz through 48kHz. Preferred
48
+ rates covering common TTS outputs are 8, 11.025, 12, 16, 22.05, 24, 32, 44.1,
49
+ and 48kHz. Other in-range rates are accepted and logged once during protocol
50
+ negotiation; the declared rate must match the actual PCM data.
51
+
52
+ ## Room token grants
53
+
54
+ The room token must include room join, publish, and subscribe grants for the
55
+ worker participant. FaceMode does not create or replace the customer LiveKit
56
+ room. Keep the token server-side and do not expose it to browser clients.
57
+
58
+ ## API URL
59
+
60
+ The default API URL is `https://api.facemode.io/api`. Set `apiUrl` for a
61
+ self-hosted or local backend.
@@ -0,0 +1,63 @@
1
+ import { voice } from '@livekit/agents';
2
+ import type { AudioFrame, Room } from '@livekit/rtc-node';
3
+ import { type LiveKitRoom } from './models.js';
4
+ export type StartOptions = {
5
+ room: LiveKitRoom;
6
+ };
7
+ export declare class AvatarSession extends voice.AvatarSession {
8
+ readonly avatarId: string;
9
+ readonly apiKey: string;
10
+ readonly apiUrl: string;
11
+ avatarParticipantIdentity: string;
12
+ readonly avatarParticipantName: string;
13
+ private room;
14
+ private session;
15
+ private websocket;
16
+ private protocolStarted;
17
+ private sessionEnded;
18
+ private protocolStartedAck;
19
+ private protocolError;
20
+ private avatarVideoPromise;
21
+ private avatarVideoResolve;
22
+ private keepaliveTimer;
23
+ private inputFormat;
24
+ private outputSlot;
25
+ private sessionLogMarker;
26
+ private sequence;
27
+ private stopped;
28
+ constructor(options: {
29
+ apiKey: string;
30
+ avatarId?: string;
31
+ apiUrl?: string;
32
+ avatarParticipantIdentity?: string;
33
+ avatarParticipantName?: string;
34
+ });
35
+ get avatarIdentity(): string;
36
+ get provider(): string;
37
+ get sessionId(): string | null;
38
+ start(agentSession: voice.AgentSession, room: Room, options?: StartOptions): Promise<void>;
39
+ waitForJoin({ timeout }?: {
40
+ timeout?: number | null;
41
+ }): Promise<void>;
42
+ stop(): Promise<void>;
43
+ aclose(): Promise<void>;
44
+ ensureProtocolStarted(sampleRate: number, channels: number): Promise<void>;
45
+ sendAudioFrame(frame: AudioFrame): Promise<void>;
46
+ sendControl(type: string, seq: number): Promise<void>;
47
+ nextSequence(): number;
48
+ private createSession;
49
+ private waitForIngestion;
50
+ private apiHeaders;
51
+ private connectWebSocket;
52
+ private bindWebSocketEvents;
53
+ private negotiateConfiguredTts;
54
+ private startApplicationKeepalive;
55
+ private stopApplicationKeepalive;
56
+ private setProtocolError;
57
+ private rollbackStart;
58
+ private requestSessionDeletion;
59
+ private resetRuntimeState;
60
+ private handleMessage;
61
+ private bindRoomVideoEvents;
62
+ private hasRemoteVideoTrack;
63
+ }
package/dist/avatar.js ADDED
@@ -0,0 +1,575 @@
1
+ import { log, voice } from '@livekit/agents';
2
+ import WebSocket from 'ws';
3
+ import { FaceModeApiError, FaceModeProtocolError } from './exceptions.js';
4
+ import { parseSessionDetails, } from './models.js';
5
+ const MIN_INPUT_SAMPLE_RATE = 8000;
6
+ const MAX_INPUT_SAMPLE_RATE = 48000;
7
+ const INGESTION_READY_TIMEOUT_MS = 60000;
8
+ const INGESTION_INITIAL_RETRY_MS = 250;
9
+ const INGESTION_MAX_RETRY_MS = 2000;
10
+ const APPLICATION_KEEPALIVE_MS = 15000;
11
+ const END_ACK_TIMEOUT_MS = 3000;
12
+ const PREFERRED_SAMPLE_RATES = new Set([
13
+ 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000,
14
+ ]);
15
+ function safeErrorType(error) {
16
+ return error instanceof Error && ['AbortError', 'TimeoutError', 'TypeError'].includes(error.name)
17
+ ? error.name
18
+ : 'Error';
19
+ }
20
+ function deferred() {
21
+ let resolvePromise;
22
+ let rejectPromise;
23
+ const promise = new Promise((resolve, reject) => {
24
+ resolvePromise = resolve;
25
+ rejectPromise = reject;
26
+ });
27
+ return { resolve: resolvePromise, reject: rejectPromise, promise };
28
+ }
29
+ class FaceModeAudioOutput extends voice.AudioOutput {
30
+ owner;
31
+ utteranceStarted = false;
32
+ constructor(owner) {
33
+ super(undefined, undefined, { pause: false });
34
+ this.owner = owner;
35
+ }
36
+ async captureFrame(frame) {
37
+ await super.captureFrame(frame);
38
+ if (!this.utteranceStarted) {
39
+ await this.owner.ensureProtocolStarted(frame.sampleRate, frame.channels);
40
+ await this.owner.sendControl('start_utterance', this.owner.nextSequence());
41
+ this.utteranceStarted = true;
42
+ this.onPlaybackStarted(Date.now());
43
+ }
44
+ await this.owner.sendAudioFrame(frame);
45
+ }
46
+ flush() {
47
+ super.flush();
48
+ if (!this.utteranceStarted)
49
+ return;
50
+ this.utteranceStarted = false;
51
+ void this.owner.sendControl('end_utterance', this.owner.nextSequence());
52
+ this.onPlaybackFinished({ playbackPosition: 0, interrupted: false });
53
+ }
54
+ clearBuffer() {
55
+ if (!this.utteranceStarted)
56
+ return;
57
+ this.utteranceStarted = false;
58
+ void this.owner.sendControl('cancel_utterance', this.owner.nextSequence());
59
+ this.onPlaybackFinished({ playbackPosition: 0, interrupted: true });
60
+ }
61
+ }
62
+ export class AvatarSession extends voice.AvatarSession {
63
+ avatarId;
64
+ apiKey;
65
+ apiUrl;
66
+ avatarParticipantIdentity;
67
+ avatarParticipantName;
68
+ room = null;
69
+ session = null;
70
+ websocket = null;
71
+ protocolStarted = null;
72
+ sessionEnded = null;
73
+ protocolStartedAck = false;
74
+ protocolError = null;
75
+ avatarVideoPromise = null;
76
+ avatarVideoResolve = null;
77
+ keepaliveTimer = null;
78
+ inputFormat = null;
79
+ outputSlot = null;
80
+ sessionLogMarker = null;
81
+ sequence = 0;
82
+ stopped = false;
83
+ constructor(options) {
84
+ super();
85
+ if (!options.apiKey)
86
+ throw new Error('apiKey is required');
87
+ this.apiKey = options.apiKey;
88
+ this.avatarId = options.avatarId ?? '';
89
+ this.apiUrl = (options.apiUrl ?? 'https://api.facemode.io/api').replace(/\/+$/, '');
90
+ this.avatarParticipantIdentity = options.avatarParticipantIdentity ?? 'facemode-avatar';
91
+ this.avatarParticipantName = options.avatarParticipantName ?? 'FaceMode Avatar';
92
+ }
93
+ get avatarIdentity() {
94
+ return this.avatarParticipantIdentity;
95
+ }
96
+ get provider() {
97
+ return 'facemode';
98
+ }
99
+ get sessionId() {
100
+ return this.session?.sessionId ?? null;
101
+ }
102
+ async start(agentSession, room, options) {
103
+ const roomConfig = normalizeLiveKitRoom(options?.room);
104
+ const roomName = roomConfig.name || room.name || tokenRoomHint(roomConfig.token);
105
+ if (!roomName)
106
+ throw new FaceModeApiError('the exact LiveKit room name is required');
107
+ if (this.avatarParticipantIdentity === 'facemode-avatar') {
108
+ const tokenIdentity = tokenIdentityHint(roomConfig.token);
109
+ if (tokenIdentity)
110
+ this.avatarParticipantIdentity = tokenIdentity;
111
+ }
112
+ let baseStarted = false;
113
+ try {
114
+ await super.start(agentSession, room);
115
+ baseStarted = true;
116
+ this.room = room;
117
+ this.stopped = false;
118
+ this.sequence = 0;
119
+ this.protocolStartedAck = false;
120
+ this.protocolError = null;
121
+ this.inputFormat = null;
122
+ this.session = await this.createSession(roomConfig, roomName);
123
+ this.session = await this.waitForIngestion(this.session);
124
+ this.sessionLogMarker = sanitizeSessionMarker(this.session.sessionId);
125
+ this.protocolStarted = deferred();
126
+ this.sessionEnded = deferred();
127
+ this.websocket = await this.connectWebSocket(this.session.ingestion.url, this.session.ingestion.wsToken, this.session.ingestion.headers);
128
+ this.bindWebSocketEvents(this.websocket);
129
+ this.startApplicationKeepalive();
130
+ await this.negotiateConfiguredTts(agentSession);
131
+ const output = agentSession.output;
132
+ if (!output) {
133
+ throw new FaceModeProtocolError('LiveKit AgentSession does not expose an output');
134
+ }
135
+ this.outputSlot = output;
136
+ output.audio = new FaceModeAudioOutput(this);
137
+ this.avatarVideoPromise = new Promise((resolve) => {
138
+ this.avatarVideoResolve = resolve;
139
+ });
140
+ this.bindRoomVideoEvents(room);
141
+ if (this.hasRemoteVideoTrack(room))
142
+ this.avatarVideoResolve?.();
143
+ log().info({ session: this.sessionLogMarker }, 'FaceMode avatar session started');
144
+ }
145
+ catch (error) {
146
+ await this.rollbackStart(baseStarted);
147
+ throw error;
148
+ }
149
+ }
150
+ async waitForJoin({ timeout = 30000 } = {}) {
151
+ if (!this.avatarVideoPromise)
152
+ return;
153
+ if (timeout === null) {
154
+ await this.avatarVideoPromise;
155
+ return;
156
+ }
157
+ let timer;
158
+ try {
159
+ await Promise.race([
160
+ this.avatarVideoPromise,
161
+ new Promise((_, reject) => {
162
+ timer = setTimeout(() => reject(new FaceModeProtocolError('Timed out waiting for FaceMode avatar video track')), timeout);
163
+ }),
164
+ ]);
165
+ }
166
+ finally {
167
+ if (timer)
168
+ clearTimeout(timer);
169
+ }
170
+ }
171
+ async stop() {
172
+ if (this.stopped)
173
+ return;
174
+ this.stopped = true;
175
+ this.stopApplicationKeepalive();
176
+ const socket = this.websocket;
177
+ const ended = this.sessionEnded?.promise;
178
+ if (socket?.readyState === WebSocket.OPEN && this.protocolStartedAck && this.session) {
179
+ try {
180
+ await this.sendControl('end_session', this.nextSequence());
181
+ if (ended)
182
+ await waitWithTimeout(ended, END_ACK_TIMEOUT_MS, 'Timed out waiting for FaceMode session end acknowledgement');
183
+ }
184
+ catch (error) {
185
+ log().warn({ session: this.sessionLogMarker, errorType: safeErrorType(error) }, 'FaceMode session shutdown acknowledgement was not received');
186
+ }
187
+ }
188
+ if (socket) {
189
+ socket.close();
190
+ this.websocket = null;
191
+ }
192
+ this.resetRuntimeState();
193
+ await super.aclose();
194
+ }
195
+ async aclose() {
196
+ await this.stop();
197
+ }
198
+ async ensureProtocolStarted(sampleRate, channels) {
199
+ if (this.protocolError)
200
+ throw this.protocolError;
201
+ if (!this.websocket
202
+ || this.websocket.readyState !== WebSocket.OPEN
203
+ || !this.session
204
+ || !this.protocolStarted) {
205
+ throw new FaceModeProtocolError('FaceMode WebSocket is not connected');
206
+ }
207
+ const inputFormat = validateInputFormat(sampleRate, channels);
208
+ if (this.inputFormat
209
+ && (this.inputFormat.sampleRate !== inputFormat.sampleRate || this.inputFormat.channels !== inputFormat.channels)) {
210
+ throw new FaceModeProtocolError(`LiveKit TTS audio format changed after negotiation: expected ${this.inputFormat.sampleRate}Hz/${this.inputFormat.channels}ch, received ${inputFormat.sampleRate}Hz/${inputFormat.channels}ch`);
211
+ }
212
+ this.inputFormat ??= inputFormat;
213
+ if (this.protocolStarted.sent) {
214
+ await this.protocolStarted.promise;
215
+ if (this.protocolError)
216
+ throw this.protocolError;
217
+ return;
218
+ }
219
+ this.protocolStarted.sent = true;
220
+ this.websocket.send(JSON.stringify({
221
+ type: 'start',
222
+ session_id: this.session.sessionId,
223
+ audio_encoding: 'pcm_s16le',
224
+ sample_rate: inputFormat.sampleRate,
225
+ channels: inputFormat.channels,
226
+ avatar_id: this.avatarId,
227
+ metadata: { source: 'livekit-agents-js' },
228
+ }));
229
+ await waitWithTimeout(this.protocolStarted.promise, 15000, 'FaceMode protocol negotiation timed out');
230
+ if (this.protocolError)
231
+ throw this.protocolError;
232
+ if (!this.protocolStartedAck) {
233
+ throw new FaceModeProtocolError('FaceMode protocol negotiation did not start');
234
+ }
235
+ }
236
+ async sendAudioFrame(frame) {
237
+ if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
238
+ throw this.protocolError ?? new FaceModeProtocolError('FaceMode WebSocket is not connected');
239
+ }
240
+ if (!this.inputFormat) {
241
+ throw new FaceModeProtocolError('FaceMode audio was sent before protocol negotiation');
242
+ }
243
+ if (frame.sampleRate !== this.inputFormat.sampleRate
244
+ || frame.channels !== this.inputFormat.channels) {
245
+ throw new FaceModeProtocolError(`LiveKit TTS audio format changed after negotiation: expected ${this.inputFormat.sampleRate}Hz/${this.inputFormat.channels}ch, received ${frame.sampleRate}Hz/${frame.channels}ch`);
246
+ }
247
+ const data = frame.data;
248
+ const buffer = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
249
+ const bytesPerSampleFrame = Int16Array.BYTES_PER_ELEMENT * this.inputFormat.channels;
250
+ if (buffer.byteLength % bytesPerSampleFrame !== 0) {
251
+ throw new FaceModeProtocolError(`LiveKit PCM frame is not aligned to ${this.inputFormat.channels} channel 16-bit samples`);
252
+ }
253
+ this.websocket.send(buffer);
254
+ }
255
+ async sendControl(type, seq) {
256
+ if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
257
+ if (this.stopped)
258
+ return;
259
+ throw this.protocolError ?? new FaceModeProtocolError('FaceMode WebSocket is not connected');
260
+ }
261
+ this.websocket.send(JSON.stringify({ type, seq }));
262
+ }
263
+ nextSequence() {
264
+ return this.sequence++;
265
+ }
266
+ async createSession(room, roomName) {
267
+ const request = {
268
+ avatarId: this.avatarId,
269
+ room,
270
+ livekit_room_id: roomName,
271
+ waitForIngestion: true,
272
+ };
273
+ const response = await fetch(`${this.apiUrl}/sessions`, {
274
+ method: 'POST',
275
+ headers: this.apiHeaders(),
276
+ body: JSON.stringify(request),
277
+ });
278
+ const payload = await parseApiJson(response, 'FaceMode session creation');
279
+ return parseSessionDetails(payload);
280
+ }
281
+ async waitForIngestion(initialSession) {
282
+ let session = initialSession;
283
+ let delay = INGESTION_INITIAL_RETRY_MS;
284
+ const deadline = Date.now() + INGESTION_READY_TIMEOUT_MS;
285
+ while (!session.ingestion.ready) {
286
+ if (Date.now() >= deadline) {
287
+ throw new FaceModeApiError('Timed out waiting for FaceMode ingestion assignment');
288
+ }
289
+ await sleep(delay);
290
+ delay = Math.min(Math.ceil(delay * 1.5), INGESTION_MAX_RETRY_MS);
291
+ const response = await fetch(`${this.apiUrl}/sessions/${encodeURIComponent(session.sessionId)}`, {
292
+ headers: this.apiHeaders(false),
293
+ });
294
+ const payload = await parseApiJson(response, 'FaceMode ingestion status');
295
+ session = parseSessionDetails(payload, session);
296
+ if (session.workerStatus === 'FAILED' || session.workerStatus === 'ENDED') {
297
+ throw new FaceModeApiError(`FaceMode ingestion worker entered ${session.workerStatus.toLowerCase()} state`);
298
+ }
299
+ }
300
+ if (!session.ingestion.url || !session.ingestion.wsToken) {
301
+ throw new FaceModeApiError('FaceMode ingestion assignment is missing WebSocket credentials');
302
+ }
303
+ return session;
304
+ }
305
+ apiHeaders(withJson = true) {
306
+ return {
307
+ Authorization: `Bearer ${this.apiKey}`,
308
+ ...(withJson ? { 'Content-Type': 'application/json' } : {}),
309
+ };
310
+ }
311
+ connectWebSocket(url, token, headers) {
312
+ return new Promise((resolve, reject) => {
313
+ const socket = new WebSocket(url, [`aivatar.${token}`], {
314
+ handshakeTimeout: 60000,
315
+ perMessageDeflate: false,
316
+ ...(headers ? { headers: { ...headers } } : {}),
317
+ });
318
+ const onOpen = () => {
319
+ socket.off('error', onError);
320
+ socket.off('close', onClose);
321
+ resolve(socket);
322
+ };
323
+ const onError = (error) => {
324
+ socket.off('open', onOpen);
325
+ socket.off('close', onClose);
326
+ reject(error);
327
+ };
328
+ const onClose = () => {
329
+ socket.off('open', onOpen);
330
+ socket.off('error', onError);
331
+ reject(new FaceModeProtocolError('FaceMode WebSocket closed before connecting'));
332
+ };
333
+ socket.once('open', onOpen);
334
+ socket.once('error', onError);
335
+ socket.once('close', onClose);
336
+ });
337
+ }
338
+ bindWebSocketEvents(socket) {
339
+ socket.on('message', (data) => this.handleMessage(data.toString()));
340
+ socket.on('error', (error) => {
341
+ const protocolError = error instanceof Error ? error : new Error(String(error));
342
+ this.setProtocolError(protocolError);
343
+ });
344
+ socket.on('close', (code) => {
345
+ this.stopApplicationKeepalive();
346
+ if (this.stopped)
347
+ return;
348
+ const protocolError = new FaceModeProtocolError(`FaceMode WebSocket closed unexpectedly (code=${code})`);
349
+ this.setProtocolError(protocolError);
350
+ log().warn({ session: this.sessionLogMarker, code }, 'FaceMode WebSocket closed unexpectedly');
351
+ });
352
+ }
353
+ async negotiateConfiguredTts(agentSession) {
354
+ const tts = agentSession.tts;
355
+ if (typeof tts?.sampleRate !== 'number' || typeof tts.numChannels !== 'number')
356
+ return;
357
+ await this.ensureProtocolStarted(tts.sampleRate, tts.numChannels);
358
+ }
359
+ startApplicationKeepalive() {
360
+ this.stopApplicationKeepalive();
361
+ this.keepaliveTimer = setInterval(() => {
362
+ if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN)
363
+ return;
364
+ try {
365
+ this.websocket.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
366
+ }
367
+ catch (error) {
368
+ this.setProtocolError(error instanceof Error ? error : new Error(String(error)));
369
+ }
370
+ }, APPLICATION_KEEPALIVE_MS);
371
+ }
372
+ stopApplicationKeepalive() {
373
+ if (!this.keepaliveTimer)
374
+ return;
375
+ clearInterval(this.keepaliveTimer);
376
+ this.keepaliveTimer = null;
377
+ }
378
+ setProtocolError(error) {
379
+ if (!this.protocolError)
380
+ this.protocolError = error;
381
+ this.protocolStarted?.reject(error);
382
+ }
383
+ async rollbackStart(baseStarted) {
384
+ const sessionId = this.session?.sessionId;
385
+ this.stopped = true;
386
+ this.stopApplicationKeepalive();
387
+ if (this.websocket) {
388
+ this.websocket.close();
389
+ this.websocket = null;
390
+ }
391
+ if (sessionId) {
392
+ await this.requestSessionDeletion(sessionId);
393
+ }
394
+ this.resetRuntimeState();
395
+ if (baseStarted)
396
+ await super.aclose();
397
+ }
398
+ async requestSessionDeletion(sessionId) {
399
+ try {
400
+ const response = await fetch(`${this.apiUrl}/sessions/${encodeURIComponent(sessionId)}`, {
401
+ method: 'DELETE',
402
+ headers: this.apiHeaders(false),
403
+ });
404
+ if (!response.ok && response.status !== 404) {
405
+ log().warn({ session: sanitizeSessionMarker(sessionId), status: response.status }, 'FaceMode startup rollback cleanup was not accepted');
406
+ }
407
+ }
408
+ catch (error) {
409
+ log().warn({ session: sanitizeSessionMarker(sessionId), errorType: safeErrorType(error) }, 'FaceMode startup rollback cleanup request failed');
410
+ }
411
+ }
412
+ resetRuntimeState() {
413
+ this.stopApplicationKeepalive();
414
+ if (this.outputSlot)
415
+ this.outputSlot.audio = null;
416
+ this.outputSlot = null;
417
+ this.room = null;
418
+ this.session = null;
419
+ this.protocolStarted = null;
420
+ this.sessionEnded = null;
421
+ this.protocolStartedAck = false;
422
+ this.protocolError = null;
423
+ this.avatarVideoPromise = null;
424
+ this.avatarVideoResolve = null;
425
+ this.inputFormat = null;
426
+ this.sessionLogMarker = null;
427
+ }
428
+ handleMessage(raw) {
429
+ let message;
430
+ try {
431
+ message = JSON.parse(raw);
432
+ }
433
+ catch {
434
+ return;
435
+ }
436
+ if (message.type === 'started') {
437
+ let error = null;
438
+ if (!this.session || String(message.session_id ?? '') !== this.session.sessionId) {
439
+ error = new FaceModeProtocolError('FaceMode started response session ID did not match');
440
+ }
441
+ else if (message.server_sample_rate !== 48000 || message.server_channels !== 1) {
442
+ error = new FaceModeProtocolError('FaceMode server reported an unsupported canonical audio format');
443
+ }
444
+ if (error) {
445
+ this.setProtocolError(error);
446
+ return;
447
+ }
448
+ this.protocolStartedAck = true;
449
+ this.protocolStarted?.resolve();
450
+ log().info({ session: this.sessionLogMarker }, 'FaceMode protocol started');
451
+ }
452
+ else if (message.type === 'error') {
453
+ const error = new FaceModeProtocolError(`${String(message.code ?? 'INTERNAL_ERROR')}: ${String(message.message ?? 'FaceMode error')}`);
454
+ if (message.fatal || !this.protocolStartedAck)
455
+ this.setProtocolError(error);
456
+ }
457
+ else if (message.type === 'session_ending') {
458
+ this.setProtocolError(new FaceModeProtocolError(`FaceMode session is ending: ${String(message.reason ?? 'unknown')}`));
459
+ }
460
+ else if (message.type === 'ended') {
461
+ this.sessionEnded?.resolve();
462
+ }
463
+ else if (message.type === 'audio_ready') {
464
+ this.avatarVideoResolve?.();
465
+ }
466
+ }
467
+ bindRoomVideoEvents(room) {
468
+ const eventRoom = room;
469
+ eventRoom.on?.('trackSubscribed', (track, _publication, participant) => {
470
+ if (track.kind === 'video' && participant.identity !== room.localParticipant?.identity) {
471
+ this.avatarVideoResolve?.();
472
+ }
473
+ });
474
+ }
475
+ hasRemoteVideoTrack(room) {
476
+ const remoteParticipants = room.remoteParticipants;
477
+ if (!remoteParticipants)
478
+ return false;
479
+ const participants = remoteParticipants instanceof Map ? [...remoteParticipants.values()] : Object.values(remoteParticipants);
480
+ return participants.some((participant) => {
481
+ const publications = participant.trackPublications instanceof Map
482
+ ? [...participant.trackPublications.values()]
483
+ : Object.values(participant.trackPublications ?? {});
484
+ return publications.some((publication) => publication.track && publication.track.kind === 'video');
485
+ });
486
+ }
487
+ }
488
+ function validateInputFormat(sampleRate, channels) {
489
+ if (!Number.isInteger(sampleRate)) {
490
+ throw new FaceModeProtocolError('LiveKit TTS sample rate must be an integer');
491
+ }
492
+ if (sampleRate < MIN_INPUT_SAMPLE_RATE || sampleRate > MAX_INPUT_SAMPLE_RATE) {
493
+ throw new FaceModeProtocolError(`LiveKit TTS sample rate must be between ${MIN_INPUT_SAMPLE_RATE} and ${MAX_INPUT_SAMPLE_RATE}: ${sampleRate}`);
494
+ }
495
+ if (!PREFERRED_SAMPLE_RATES.has(sampleRate)) {
496
+ log().info({ sampleRate }, 'using uncommon LiveKit TTS sample rate');
497
+ }
498
+ if (![1, 2].includes(channels)) {
499
+ throw new FaceModeProtocolError(`Unsupported LiveKit TTS channel count: ${channels}`);
500
+ }
501
+ return { sampleRate, channels };
502
+ }
503
+ async function parseApiJson(response, operation) {
504
+ const body = await response.text();
505
+ if (!response.ok) {
506
+ throw new FaceModeApiError(`${operation} failed (${response.status})`);
507
+ }
508
+ try {
509
+ const payload = JSON.parse(body);
510
+ if (!payload || typeof payload !== 'object')
511
+ throw new Error('not an object');
512
+ return payload;
513
+ }
514
+ catch {
515
+ throw new FaceModeApiError(`${operation} response was not JSON`);
516
+ }
517
+ }
518
+ function waitWithTimeout(promise, timeout, message) {
519
+ let timer;
520
+ return Promise.race([
521
+ promise,
522
+ new Promise((_, reject) => {
523
+ timer = setTimeout(() => reject(new FaceModeProtocolError(message)), timeout);
524
+ }),
525
+ ]).finally(() => {
526
+ if (timer)
527
+ clearTimeout(timer);
528
+ });
529
+ }
530
+ function sleep(milliseconds) {
531
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
532
+ }
533
+ function sanitizeSessionMarker(sessionId) {
534
+ return sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96);
535
+ }
536
+ function normalizeLiveKitRoom(value) {
537
+ if (!value || value.type !== 'livekit') {
538
+ throw new Error("room.type must be 'livekit'");
539
+ }
540
+ if (typeof value.url !== 'string' || !value.url) {
541
+ throw new Error('room.url is required');
542
+ }
543
+ if (typeof value.token !== 'string' || !value.token) {
544
+ throw new Error('room.token is required');
545
+ }
546
+ const name = typeof value.name === 'string' && value.name ? value.name : undefined;
547
+ return {
548
+ type: 'livekit',
549
+ url: value.url,
550
+ token: value.token,
551
+ ...(name ? { name } : {}),
552
+ };
553
+ }
554
+ function tokenIdentityHint(token) {
555
+ const payload = tokenClaimsHint(token);
556
+ return typeof payload?.sub === 'string' && payload.sub ? payload.sub : undefined;
557
+ }
558
+ function tokenRoomHint(token) {
559
+ const payload = tokenClaimsHint(token);
560
+ const video = payload?.video;
561
+ if (!video || typeof video !== 'object')
562
+ return undefined;
563
+ const room = video.room;
564
+ return typeof room === 'string' && room ? room : undefined;
565
+ }
566
+ function tokenClaimsHint(token) {
567
+ try {
568
+ const part = token.split('.')[1];
569
+ const payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
570
+ return payload && typeof payload === 'object' ? payload : undefined;
571
+ }
572
+ catch {
573
+ return undefined;
574
+ }
575
+ }
@@ -0,0 +1,9 @@
1
+ export declare class FaceModeError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare class FaceModeApiError extends FaceModeError {
5
+ constructor(message: string);
6
+ }
7
+ export declare class FaceModeProtocolError extends FaceModeError {
8
+ constructor(message: string);
9
+ }
@@ -0,0 +1,18 @@
1
+ export class FaceModeError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'FaceModeError';
5
+ }
6
+ }
7
+ export class FaceModeApiError extends FaceModeError {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'FaceModeApiError';
11
+ }
12
+ }
13
+ export class FaceModeProtocolError extends FaceModeError {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'FaceModeProtocolError';
17
+ }
18
+ }
@@ -0,0 +1,5 @@
1
+ export { AvatarSession, type StartOptions } from './avatar.js';
2
+ export { FaceModeApiError, FaceModeError, FaceModeProtocolError, } from './exceptions.js';
3
+ export { parseSessionDetails } from './models.js';
4
+ export type { IngestionDetails, LiveKitRoom, SessionDetails, SessionRequest } from './models.js';
5
+ export { VERSION } from './version.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { AvatarSession } from './avatar.js';
2
+ export { FaceModeApiError, FaceModeError, FaceModeProtocolError, } from './exceptions.js';
3
+ export { parseSessionDetails } from './models.js';
4
+ export { VERSION } from './version.js';
@@ -0,0 +1,28 @@
1
+ export interface LiveKitRoom {
2
+ readonly type: 'livekit';
3
+ readonly url: string;
4
+ readonly token: string;
5
+ readonly name?: string;
6
+ }
7
+ export interface SessionRequest {
8
+ readonly avatarId: string;
9
+ readonly room: LiveKitRoom;
10
+ readonly livekit_room_id: string;
11
+ readonly waitForIngestion: boolean;
12
+ }
13
+ export interface IngestionDetails {
14
+ readonly ready: boolean;
15
+ readonly authority?: 'control' | 'websocket' | string;
16
+ readonly url?: string;
17
+ readonly wsToken?: string;
18
+ readonly headers?: Readonly<Record<string, string>>;
19
+ }
20
+ export interface SessionDetails {
21
+ readonly sessionId: string;
22
+ readonly roomName: string;
23
+ readonly room: LiveKitRoom;
24
+ readonly ingestion: IngestionDetails;
25
+ readonly workerStatus?: string;
26
+ readonly avatarParticipantIdentity?: string;
27
+ }
28
+ export declare function parseSessionDetails(payload: Record<string, unknown>, fallback?: Pick<SessionDetails, 'room' | 'roomName'>): SessionDetails;
package/dist/models.js ADDED
@@ -0,0 +1,73 @@
1
+ export function parseSessionDetails(payload, fallback) {
2
+ const root = isRecord(payload.data) ? payload.data : payload;
3
+ const session = isRecord(root.session) ? root.session : {};
4
+ const ingestion = isRecord(root.ingestion)
5
+ ? root.ingestion
6
+ : isRecord(session.ingestion)
7
+ ? session.ingestion
8
+ : {};
9
+ const sessionId = firstText(session.id, session.sessionId, root.sessionId, root.id, root.jobId);
10
+ if (!sessionId) {
11
+ throw new Error('FaceMode session response is missing a session ID');
12
+ }
13
+ const url = firstText(ingestion.url, ingestion.websocketUrl, root.websocketUrl, root.websocket_url);
14
+ const wsToken = firstText(ingestion.wsToken, ingestion.ws_token, ingestion.token, root.ingestionToken, root.ingestion_token);
15
+ const ready = ingestion.ready === true || (!('ready' in ingestion) && Boolean(url && wsToken));
16
+ if (ready && (!url || !wsToken)) {
17
+ throw new Error('FaceMode reported ready ingestion without WebSocket credentials');
18
+ }
19
+ const headers = parseHeaders(ingestion.headers);
20
+ return {
21
+ sessionId,
22
+ roomName: firstText(root.roomName, session.roomName, fallback?.roomName),
23
+ room: parseRoom(root.room ?? session.room, root, session, fallback?.room),
24
+ ingestion: {
25
+ ready,
26
+ ...(typeof ingestion.authority === 'string' ? { authority: ingestion.authority } : {}),
27
+ ...(url ? { url } : {}),
28
+ ...(wsToken ? { wsToken } : {}),
29
+ ...(headers ? { headers } : {}),
30
+ },
31
+ ...(firstText(root.workerStatus, root.worker_status, session.workerStatus, session.worker_status)
32
+ ? { workerStatus: firstText(root.workerStatus, root.worker_status, session.workerStatus, session.worker_status) }
33
+ : {}),
34
+ ...(firstText(root.avatarParticipantIdentity, root.avatar_participant_identity, session.avatarParticipantIdentity, session.avatar_participant_identity)
35
+ ? {
36
+ avatarParticipantIdentity: firstText(root.avatarParticipantIdentity, root.avatar_participant_identity, session.avatarParticipantIdentity, session.avatar_participant_identity),
37
+ }
38
+ : {}),
39
+ };
40
+ }
41
+ function parseRoom(value, root, session, fallback) {
42
+ if (isRecord(value) && value.type === 'livekit') {
43
+ const url = value.url;
44
+ const token = value.token;
45
+ if (typeof url === 'string' && url && typeof token === 'string' && token) {
46
+ const name = typeof value.name === 'string' && value.name ? value.name : undefined;
47
+ return { type: 'livekit', url, token, ...(name ? { name } : {}) };
48
+ }
49
+ }
50
+ if (fallback)
51
+ return fallback;
52
+ throw new Error('FaceMode session response is missing a LiveKit room');
53
+ }
54
+ function parseHeaders(value) {
55
+ if (!isRecord(value))
56
+ return undefined;
57
+ const headers = {};
58
+ for (const [name, headerValue] of Object.entries(value)) {
59
+ if (typeof headerValue === 'string' && name)
60
+ headers[name] = headerValue;
61
+ }
62
+ return Object.keys(headers).length ? headers : undefined;
63
+ }
64
+ function isRecord(value) {
65
+ return typeof value === 'object' && value !== null;
66
+ }
67
+ function firstText(...values) {
68
+ for (const value of values) {
69
+ if (value !== undefined && value !== null && String(value))
70
+ return String(value);
71
+ }
72
+ return '';
73
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION = "0.1.0";
@@ -0,0 +1 @@
1
+ export const VERSION = '0.1.0';
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@facemode/agents-plugin-facemode",
3
+ "version": "0.1.0",
4
+ "description": "FaceMode avatar integration for LiveKit Agents",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist", "LICENSE"],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "npm run build && node --test test/models.test.js test/avatar.test.js",
21
+ "prepare": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "@livekit/agents": "1.6.2",
25
+ "@livekit/rtc-node": "0.13.33",
26
+ "ws": "8.21.3"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "22.15.30",
30
+ "@types/ws": "8.18.1",
31
+ "typescript": "5.8.3"
32
+ }
33
+ }